本文概览:本文从实际场景入手,系统讲解Redis的核心知识,涵盖缓存设计基础(缓存策略、Key设计规范)、缓存击穿/穿透/雪崩三大问题的解决方案,以及秒杀与一人一单等业务场景下的分布式锁实战应用


第一部分:Redis 的核心应用场景

一、无 Redis 的典型问题

场景1:高并发读取商品详情(如电商首页)

  • 无 Redis 时
    • 每次用户访问商品页,都直接查询 MySQL 数据库。
    • 若该商品是爆款(如秒杀商品),每秒可能有上万次请求打到数据库。
  • 后果:数据库 CPU/IO 飙升,响应变慢甚至崩溃;用户体验差(页面加载慢或超时)。

场景2:分布式系统中的用户登录状态

  • 无 Redis 时
    • 用户登录后,Session 存在某一台 Web 服务器的内存中。
    • 如果后续请求被负载均衡分配到其他服务器,新服务器无法识别用户身份。
  • 后果:用户频繁掉登录,系统无法实现真正的分布式部署。

场景3:实时点赞/计数功能

  • 无 Redis 时
    • 每次点赞都要执行 UPDATE posts SET likes = likes + 1 WHERE id = ?
    • 高并发下,数据库行锁竞争激烈,容易造成死锁或性能瓶颈。
  • 后果:点赞失败、延迟高,数据一致性难以保证。

场景4:排行榜(如游戏积分榜)

  • 无 Redis 时
    • 每次都要从 MySQL 中 ORDER BY score DESC LIMIT 100 查询。
    • 数据量大时,即使有索引,排序操作仍很耗时。
  • 后果:排行榜加载慢,影响互动体验。

场景5:地理位置服务(如“附近的人”、“附近门店”)

  • 无 Redis 时
    • 所有用户/门店的经纬度存储在 MySQL 中。

    • 每次查询“附近 5 公里内的用户”,需执行类似:

      1
      2
      SELECT * FROM users 
      WHERE (6371 * acos(cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat)))) <= 5;
    • 这种计算涉及大量三角函数,即使加了空间索引(如 MySQL 的 SPATIAL INDEX),性能仍较差。

  • 后果:响应慢(几百毫秒甚至秒级),高并发下数据库 CPU 飙升。

场景6:UV(Unique Visitor,独立访客)统计

  • 无 Redis 时
    • 常见做法:记录每个用户的访问日志到数据库或日志文件,再通过
      SELECT COUNT(DISTINCT user_id) FROM logs WHERE date = '2026-04-26' 统计 UV。
  • 问题
    • 数据量大时(如千万级日活),COUNT(DISTINCT) 极其耗时。
    • 若用布隆过滤器自研,开发成本高且难保证精度。
    • 实时性差:通常只能 T+1 报表,无法实时展示“今日 UV”。

二、引入 Redis 后的改进

问题场景 无 Redis 的痛点 引入 Redis 后的解决方案 效果
热点数据读取 数据库压力大,响应慢 将商品详情缓存到 Redis,设置 TTL(如 5 分钟) 数据库 QPS 下降 90%+,响应时间从 50ms → 1ms
分布式 Session 用户登录状态不共享 所有服务器将 Session 存入 Redis(Key: session:user123 实现无缝分布式登录,用户无感知
高并发计数 数据库更新慢、易锁表 使用 INCR like:post:1001 原子操作 支持 10w+ QPS 计数,毫秒级响应
实时排行榜 排序慢、资源消耗大 使用 Sorted Set(ZSet)存储用户 ID 和分数,ZREVRANGE 获取 Top N 毫秒级返回榜单,支持动态更新
防止重复提交 依赖数据库唯一索引,失败率高 提交前先 SET order_lock:order123 EX 30 NX 加锁 有效避免重复下单、超卖等问题
地理位置服务 查询附近对象需复杂计算,响应慢且数据库负载高 使用 GEO 结构:
GEOADD locations 116.404 39.915 "user123"
GEORADIUS locations 116.404 39.915 5 km
毫秒级返回附近结果,支持高并发 LBS 应用
UV 统计 无法实时统计,COUNT(DISTINCT) 资源消耗大 使用 HyperLogLog:
PFADD uv:hll:20260426 user123
PFCOUNT uv:hll:20260426
内存仅占约 12KB,误差 <1%,支持实时 UV 监控

第二部分:缓存设计基础

一、为什么要使用缓存

1.1 减小数据库压力(核心原因)

这是使用缓存最重要的原因。 当高并发请求直接打到数据库时,数据库很容易因为连接数耗尽、CPU/内存过载而崩溃。

真实场景:
电商大促期间,商品详情页可能面临每秒10万次的访问。如果这些请求都直接查询数据库:

  • 数据库连接池可能只有几百个连接
  • 每次查询需要50-100ms
  • 数据库很快就会因为连接耗尽而拒绝服务

缓存的作用:
通过缓存拦截95%以上的请求,数据库只需处理5%的缓存未命中请求,压力降低20倍。

1.2 性能提升

缓存将数据存储在内存中,访问速度比磁盘快100-1000倍:

  • 数据库查询:50-100ms
  • Redis查询:0.1-1ms
  • 性能提升:50-100倍

1.3 成本优化

硬件成本节约:
在没有缓存的情况下,为了支撑高并发访问,需要部署大量的数据库服务器。假设一个电商平台日均访问量1000万,商品详情页占比30%(300万次访问),如果缓存命中率达到95%,那么每天可以减少285万次数据库查询。

按照每次数据库查询成本0.01元计算:

  • 无缓存成本:300万 × 0.01 = 3万元/天
  • 有缓存成本:300万 × 5% × 0.01 = 1500元/天
  • 每日节省:2.85万元

运维成本节约:

  • 减少数据库服务器数量,降低硬件采购和维护成本
  • 降低数据库负载,延长硬件使用寿命
  • 减少数据库扩容频率,降低运维复杂度

带宽成本节约:
缓存减少了数据库和应用服务器之间的网络传输,降低了带宽消耗。特别是在跨机房、跨地域部署的场景下,缓存可以显著降低网络延迟和带宽成本。

1.4 用户体验改善

页面加载速度:
用户对页面加载时间的容忍度非常有限:

  • 0-1秒:优秀体验,用户满意度高
  • 1-3秒:可接受,但用户开始感到不耐烦
  • 3-5秒:开始流失,约25%的用户会离开
  • 5秒以上:大量流失,超过50%的用户会放弃访问

缓存让页面加载时间稳定在1秒内,显著提升用户留存率和转化率。

系统稳定性:
在高并发场景下,缓存可以起到"削峰填谷"的作用:

  • 大促期间,缓存可以吸收突发流量,避免数据库被瞬间击垮
  • 即使数据库短暂故障,缓存仍然可以提供服务,保证系统可用性
  • 缓存的高可用架构(如Redis集群)可以提供更好的容灾能力

业务连续性:

  • 缓存可以作为数据库的"缓冲层",在数据库升级、维护期间提供服务
  • 支持灰度发布和A/B测试,通过缓存控制流量分配
  • 提升系统的弹性和容错能力

二、什么数据适合缓存(结合数据结构设计)

2.1 读多写少的数据:商品详情页

业务特征:
商品详情页是电商系统中访问频率最高的页面之一。一个热门商品可能被成千上万的用户浏览,但商品信息(如名称、价格、描述)一天可能只更新几次。这类数据的特点是读取频率极高,写入频率极低,非常适合缓存。

Key设计思路:

1
product:detail:{productId}:v1
  • product:detail: 作为业务前缀,清晰表明这是商品详情数据
  • {productId} 作为唯一标识符,支持快速定位
  • :v1 作为版本号,便于数据结构变更时平滑升级

这种设计的好处是:

  1. 通过前缀可以快速识别数据类型,便于管理和监控
  2. 通过productId可以精确定位到具体商品
  3. 版本号支持数据结构升级,避免全量更新带来的风险

Value设计与数据结构选择:
商品详情是一个完整的对象,包含多个字段(名称、价格、库存、描述、图片等),适合使用String类型存储序列化后的JSON。虽然Hash也可以存储对象,但商品详情通常需要整体读取和更新,使用String更简单高效。

1
2
3
4
5
6
7
8
9
10
{
"id": 1001,
"name": "iPhone 15",
"price": 5999,
"stock": 100,
"description": "最新款iPhone",
"images": ["url1", "url2"],
"category": "手机",
"brand": "Apple"
}

过期策略设计:
商品详情的过期时间需要平衡数据新鲜度和缓存命中率。设置1小时的过期时间(根据实际场景设置)是比较合理的:

  • 太短:频繁重建缓存,增加数据库压力
  • 太长:数据更新延迟,影响用户体验

为了防止缓存雪崩,采用随机过期策略:基础过期时间1小时 + 随机偏移0-10分钟。这样可以避免大量商品缓存在同一时间过期。


2.2 计算成本高的数据:用户订单统计

业务特征:
用户经常查看自己的消费统计,包括订单数量、总消费金额、平均订单金额等。这些统计数据需要JOIN订单表、商品表、用户表等多个表进行复杂计算,每次查询都需要执行复杂的SQL语句,成本很高。

Key设计思路:

1
user:order:stats:{userId}:{date}:v1
  • user:order:stats: 表明这是用户订单统计数据
  • {userId} 标识具体用户
  • {date} 增加时间维度,支持按天、按月查询
  • :v1 版本号

Value设计与数据结构选择:
订单统计包含多个独立的字段(订单数、总金额、平均金额等),适合使用Hash数据结构。Hash的优势在于:

  1. 可以按字段读取,节省带宽
  2. 更新单个字段不影响其他字段
  3. 内存占用比String更小
1
2
3
4
5
6
HSET user:order:stats:1001:20260426:v1 \
total_amount 15000 \
order_count 15 \
avg_amount 1000 \
max_amount 5000 \
min_amount 100

过期策略设计:
订单统计数据相对稳定,一天内的数据不会变化。设置24小时的过期时间比较合适。可以在每天凌晨定时更新所有用户的统计数据,然后重新写入缓存。


2.3 热点数据:秒杀商品库存

业务特征:
秒杀活动是典型的高并发场景,每秒可能有数万次查询。库存数据量小,但实时性要求高,需要快速响应。这类数据的特点是并发极高、数据量小、实时性要求高。

Key设计思路:

1
seckill:stock:{activityId}:{productId}:v1
  • seckill:stock: 表明这是秒杀库存数据
  • {activityId} 标识具体活动
  • {productId} 标识具体商品
  • :v1 版本号

Value设计与数据结构选择:
库存是一个简单的整数,适合使用String类型存储。选择String而不是Hash的原因是:

  1. 库存只需要存储一个数字,不需要复杂结构
  2. String支持原子操作(INCR、DECR),适合高并发场景
  3. 内存占用最小
1
SET seckill:stock:2026042601:1001:v1 1000

过期策略设计:
秒杀库存的过期时间应该与活动时间一致。例如活动持续1小时,就设置1小时的过期时间。活动结束后,库存数据自动过期,避免占用缓存空间。


2.4 配置和字典数据:系统配置

业务特征:
系统配置、支付配置、地区信息等数据几乎不变化,但每个请求都可能需要。这类数据的特点是几乎不变化、查询频率高、数据量小。

Key设计思路:

1
config:{module}:{key}:v1
  • config: 表明这是配置数据
  • {module} 标识配置模块(如system、payment、region)
  • {key} 标识具体配置项
  • :v1 版本号

Value设计与数据结构选择:
配置数据通常包含多个键值对,适合使用Hash数据结构。Hash的优势在于:

  1. 可以存储多个配置项,结构清晰
  2. 支持按字段读取和更新
  3. 内存占用比多个String更小
1
2
3
4
5
6
7
8
9
10
11
HSET config:payment:alipay \
app_id "2021001234567890" \
private_key "MIIEvQIBADANBgkqhkiG..." \
notify_url "https://example.com/notify" \
timeout "30s"

HSET config:region:china \
beijing "北京" \
shanghai "上海" \
guangzhou "广州" \
shenzhen "深圳"

过期策略设计:
配置数据几乎不变化,可以设置较长的过期时间(如7天)或永不过期。为了保证配置更新的及时性,可以配合后台定时任务定期刷新缓存。


2.5 会话和临时数据:用户购物车

业务特征:
用户会话和购物车数据是临时性的,但访问频率很高。购物车需要支持商品的增删改查,会话数据需要支持用户登录状态的维护。

Key设计思路:

1
2
cart:{userId}:v1
session:{sessionId}:v1
  • cart:session: 表明数据类型
  • {userId}{sessionId} 标识具体用户或会话
  • :v1 版本号

Value设计与数据结构选择:
购物车包含多个商品和对应的数量,适合使用Hash数据结构。Hash的优势在于:

  1. 可以存储多个商品,结构清晰
  2. 支持按商品读取和更新
  3. 支持原子操作(HINCRBY),适合并发场景
1
2
3
HSET cart:1001:v1 1001 2  # 商品1001,数量2
HSET cart:1001:v1 1002 1 # 商品1002,数量1
HSET cart:1001:v1 1003 3 # 商品1003,数量3

过期策略设计:
购物车数据是临时性的,可以设置7天的过期时间。用户长时间不访问购物车,数据自动清理,避免占用缓存空间。


三、Key设计最佳实践

3.1 分层命名规范

1
{业务域}:{数据类型}:{标识符}:{维度}:{版本}

示例:

  • product:detail:1001:v1 - 商品详情
  • user:order:list:1001:v1 - 用户订单列表
  • seckill:stock:2026042601:1001:v1 - 秒杀库存

3.2 版本控制

当数据结构变更时,通过版本号平滑升级:

1
2
3
4
5
6
7
8
// 旧版本
product:detail:1001:v1 -> {"name":"iPhone","price":5999}

// 新版本
product:detail:1001:v2 -> {"name":"iPhone","price":5999,"discount":0.9}

// 原子性切换
RENAME product:detail:1001:v2 product:detail:1001:v1

3.3 过期时间设计

  • 固定过期: 适合一般数据(如1小时)
  • 随机过期: 防止雪崩(1小时 + 0-10分钟随机)
  • 永不过期: 热点数据,配合后台更新
  • 逻辑过期: 需要平滑更新的场景

第三部分:缓存问题实战

一、数据不一致问题

例如: 缓存存储库存数量后,当库存数据库数据进行了更新操作,此时如果不对缓存进行更新或者删除,下次用户查询会直接得到缓存数据,此时缓存的数据为"假数据",与数据库的真实数据不一致,用户得到了错误的库存信息导致引发业务问题,这就称为数据不一致问题

1.1 缓存与数据库的最终一致性

一、初始方案:先删除缓存,再更新数据库

这个方案的思路很简单:先删除缓存,再更新数据库,这样可以避免查询到旧数据。

但是问题来了:
假设在高并发场景下:

  1. 线程A删除缓存
  2. 线程B查询缓存(未命中),查询数据库(得到旧数据)
  3. 线程A更新数据库(成功)
  4. 线程B将旧数据写入缓存

结果: 缓存中存储的是旧数据,出现数据不一致。

根本原因: 高并发下,线程B在缓存删除后、数据库更新前查询到了旧数据,并将旧数据写入缓存。


二、改进方案:先更新数据库,再删除缓存(Cache-Aside)

这个方案反过来:先更新数据库,再删除缓存。

看似解决了问题,但新的问题出现了:
在高并发场景下:

  1. 线程A查询缓存(未命中),查询数据库(得到旧数据)
  2. 线程B更新数据库(成功),删除缓存
  3. 线程A将旧数据写入缓存

结果: 缓存中仍然存储的是旧数据,数据不一致。

为什么会这样?
因为线程A在查询数据库后、写入缓存前,线程B已经更新了数据库并删除了缓存。但此时缓存已经被删除,线程A的写入操作会将旧数据重新写入缓存。

这种问题发生的概率:
虽然这种情况发生的概率较低(需要线程A查询数据库后、写入缓存前,线程B完成更新和删除操作),但在高并发场景下,仍然可能发生。


三、进一步改进:延迟双删 + TTL兜底

为了解决上述问题,引入延迟双删策略:

  1. 先删除缓存
  2. 更新数据库
  3. 延迟一段时间(如500ms)
  4. 再次删除缓存
  5. 新写入的缓存设置一个TTL,保证即使延迟双删出现问题,错误的缓存数据也不会长时间存在,导致用户长期接受错误数据

原理:
延迟的目的是等待所有可能的并发查询完成。第一次删除是为了防止旧数据被查询到,第二次删除是为了清理可能被并发查询写入的脏数据。

但是问题依然存在:

  • 延迟时间难以确定: 太短可能清理不干净,太长影响性能
  • 第二次删除可能失败: 如果第二次删除失败,仍然会有脏数据
  • 增加系统复杂度: 需要引入延迟机制

延迟时间的选择:
延迟时间应该大于查询数据库和写入缓存的时间。通常设置为500ms-1s,但这个时间需要根据具体业务场景调整。


四、终极方案:消息队列异步更新

将缓存更新操作异步化:

  1. 更新数据库
  2. 发送消息到消息队列
  3. 消费者异步删除/更新缓存

优势:

  • 解耦: 数据库操作和缓存操作分离,互不影响
  • 可靠性: 消息队列保证消息不丢失,缓存更新失败可以重试
  • 削峰填谷: 消息队列可以缓冲突发的缓存更新请求

缺点:

  • 延迟较高: 消息队列的延迟通常在几十毫秒到几百毫秒
  • 实现复杂: 需要引入消息队列,增加系统复杂度
  • 成本较高: 需要维护消息队列,增加运维成本

适用场景:

  • 对一致性要求极高的场景
  • 更新频率不高的场景
  • 可以接受一定延迟的场景

1.2 业务操作的原子性与正确性

一、初始方案:先查后减(非原子操作)

在秒杀场景下,最直观的做法是先查询库存,判断库存是否充足,然后扣减库存:

1
2
3
4
5
6
7
8
// 查询库存
Integer stock = redisTemplate.opsForValue().get(stockKey);
if (stock > 0) {
// 扣减库存
redisTemplate.opsForValue().decrement(stockKey);
// 创建订单
createOrder();
}

问题场景:
假设库存为1,两个用户同时发起秒杀请求:

  1. 用户A查询库存,得到1
  2. 用户B查询库存,得到1
  3. 用户A判断库存充足,扣减库存,库存变为0
  4. 用户B判断库存充足,扣减库存,库存变为-1

结果: 库存从1变成了-1,但实际上应该只扣减1次,库存应该为0。这就是超卖问题。

根本原因: 查询库存和扣减库存不是原子操作,多个线程可以同时读取到相同的库存值。


二、改进方案二:Lua脚本

将查询、判断、扣减操作打包成原子操作:

1
2
3
4
5
6
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil or stock <= 0 then
return 0
end
redis.call('DECR', KEYS[1])
return stock - 1

原理:
Lua脚本在Redis中是原子执行的,整个脚本执行期间不会被其他命令打断。

优势:

  • 原子性: 整个业务逻辑原子执行,不会出现超卖
  • 灵活性: 可以处理复杂的业务逻辑

局限性:

  • 需要编写和维护Lua脚本: 增加了开发和维护成本
  • 调试困难: Lua脚本的调试比Java代码困难

三、改进方案3:CAS(Compare And Set)乐观锁(若无购买次数限制问题,该方案最好)

  1. 版本号方案
1
2
3
4
// 数据库表增加version字段
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = ? AND version = ?
  • 原理:
    每次更新时检查version版本号,如果版本号不匹配,说明数据已经被其他线程修改,更新失败。

  • 优势:

    • 不需要加锁: 性能好,适合读多写少的场景
    • 简单易用: 只需要在SQL中增加version条件
  • 缺点:

    • 更新失败需要重试: 如果更新失败,需要重试,增加了复杂度
    • 不适合写多的场景: 如果并发写入很高,重试次数会很多
  1. CAS方案
    1
    2
    3
    4
    // 数据库表增加version字段
    UPDATE products
    SET stock = stock - 1, version = version + 1
    WHERE id = ? AND stock > 0
  • 原理:
    每次更新时检查库存数量是否大于0,如果版本号不大于,说明数据已经没有库存了,更新失败。

  • 优势:

    • 不需要加锁: 性能好,适合读多写少的场景
    • 不需要额外添加字段: 减少存储量,方便维护
  • 缺点:

    • 更新失败需要重试: 如果更新失败,需要重试,增加了复杂度
    • ABA 问题: 值从 A → B → A,CAS 认为“没变”,但实际上中间发生了变化,库存场景无问题,但是在一些如订单状态等的场景下容易出问题

四、改进方案4:分布式锁

使用分布式锁保证同一时间只有一个线程可以操作库存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 获取分布式锁
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(10));

if (Boolean.TRUE.equals(locked)) {
try {
// 查询库存
Integer stock = redisTemplate.opsForValue().get(stockKey);
if (stock > 0) {
// 扣减库存
redisTemplate.opsForValue().decrement(stockKey);
// 创建订单
createOrder();
}
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
}

优势:

  • 通用性强: 适合各种场景
  • 保证数据一致性: 同一时间只有一个线程可以操作库存

缺点:

  • 性能较低: 需要获取和释放锁,性能比原子操作低
  • 有死锁风险: 如果忘记释放锁,会导致死锁
  • 锁竞争激烈时性能下降: 如果并发很高,锁竞争会很激烈

二、缓存失效问题

2.1 缓存击穿(Cache Breakdown)

一、问题定义与场景

缓存击穿是指热点Key过期的瞬间,大量并发请求同时访问该Key,导致所有请求都打到数据库。

具体场景:
电商大促,爆款商品"iPhone 15"的缓存过期,10000个用户同时访问,所有请求都查询数据库,导致数据库压力骤增。

为什么会发生:

  • 热点Key设置了固定的过期时间
  • 过期时间到达时,所有请求同时发现缓存失效
  • 所有请求同时查询数据库,导致数据库压力骤增

二、源头处理 : 如果采取了设置TTL的方案,在数据缓存的TTL设置加上随机数,避免同时过期

三、初始方案:无防护
最简单的做法是什么都不做,缓存过期后直接查询数据库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public Product getProduct(Long productId) {
String cacheKey = "product:detail:" + productId + ":v1";

// 查询缓存
Product product = redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}

// 缓存未命中,查询数据库
product = productDao.findById(productId);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, product, Duration.ofHours(1));
}
return product;
}

问题:
缓存过期瞬间,所有请求都查询数据库,导致数据库压力骤增。


四、改进方案1:互斥锁(Mutex Lock)

当缓存失效时,使用分布式锁保证只有一个线程可以查询数据库并重建缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public Product getProductWithLock(Long productId) {
String cacheKey = "product:detail:" + productId + ":v1";
String lockKey = "lock:product:" + productId;

// 1. 查询缓存
Product product = redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}

// 2. 获取分布式锁
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(10));

if (Boolean.TRUE.equals(locked)) {
try {
// 双重检查:防止其他线程已经重建缓存
product = redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}

// 3. 查询数据库并重建缓存
product = productDao.findById(productId);
if (product != null) {
redisTemplate.opsForValue()
.set(cacheKey, product, Duration.ofHours(1));
}
return product;
} finally {
// 4. 释放锁
redisTemplate.delete(lockKey);
}
} else {
// 未获取到锁,等待并重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProductWithLock(productId);
}
}

优势:

  • 简单有效: 实现简单,效果明显
  • 保证一致性: 只有一个线程可以重建缓存

问题:

  • 等待锁的线程会有延迟: 用户体验下降
  • 锁竞争激烈时性能下降: 如果并发很高,锁竞争会很激烈

五、改进方案2:逻辑过期(Logical Expire)

缓存中存储逻辑过期时间,应用层判断是否过期。过期后异步重建缓存,同时返回旧数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Data
public class ProductWrapper {
private Product product;
private Long expireTime; // 逻辑过期时间(毫秒时间戳)
}

public Product getProductWithLogicalExpire(Long productId) {
String cacheKey = "product:detail:" + productId + ":v1";

// 1. 查询缓存(不判断物理过期)
String cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (cacheValue != null) {
ProductWrapper wrapper = JSON.parseObject(cacheValue, ProductWrapper.class);

// 2. 判断逻辑是否过期
if (System.currentTimeMillis() < wrapper.getExpireTime()) {
return wrapper.getProduct(); // 未过期,直接返回
}

// 3. 逻辑过期,异步重建缓存
rebuildCacheAsync(productId);
return wrapper.getProduct(); // 先返回旧数据
}

// 4. 缓存不存在,同步重建
return rebuildCache(productId);
}

优势:

  • 响应快: 即使逻辑过期,也能立即返回旧数据
  • 用户体验好: 用户不会感知到缓存重建的过程

问题:

  • 实现复杂: 需要维护逻辑过期时间
  • 有短暂的数据不一致: 逻辑过期后,返回的是旧数据

六、 改进方案3:热点数据永不过期

真正的热点数据设置永不过期,通过后台定时任务定期更新:

1
2
3
4
5
6
7
8
9
10
11
@Scheduled(fixedRate = 300000)  // 每5分钟更新一次
public void updateHotProducts() {
List<Long> hotProductIds = getHotProductIds(); // 获取热点商品ID
hotProductIds.forEach(productId -> {
Product product = productDao.findById(productId);
if (product != null) {
String cacheKey = "product:hot:" + productId + ":v1";
redisTemplate.opsForValue().set(cacheKey, product);
}
});
}

优势:

  • 性能最好: 不会有缓存失效的问题
  • 用户体验最好: 始终有缓存可用

问题:

  • 需要维护后台任务: 增加了系统复杂度
  • 数据可能延迟: 后台任务更新频率有限,数据可能延迟

2.2 缓存穿透(Cache Penetration)

一、问题定义与场景

缓存穿透是指查询不存在的数据,由于缓存中没有,每次都查询数据库。

具体场景:

  1. 恶意攻击: 攻击者构造大量不存在的商品ID(如999999999),每次查询都穿透到数据库
  2. 业务场景: 用户查询已下架的商品,或者传入无效的参数
  3. 参数错误: 前端传入错误的商品ID,导致查询不存在的数据

为什么会发生:

  • 查询的数据在数据库中不存在
  • 缓存中没有存储空值
  • 每次查询都穿透到数据库

二、初始方案:无防护

最简单的做法是什么都不做,查询不到数据就不缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public Product getProduct(Long productId) {
String cacheKey = "product:detail:" + productId + ":v1";

// 查询缓存
Product product = redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}

// 缓存未命中,查询数据库
product = productDao.findById(productId);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, product, Duration.ofHours(1));
}
// 不存在的数据不缓存,下次还会查询数据库
return product;
}

问题:
查询不存在的数据时,每次都查询数据库,导致数据库压力增大。


三、改进方案1:空值缓存(Null Cache)

查询数据库发现数据不存在时,将空值缓存起来,设置较短的过期时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public Product getProductWithNullCache(Long productId) {
String cacheKey = "product:detail:" + productId + ":v1";

// 查询缓存
Object cacheValue = redisTemplate.opsForValue().get(cacheKey);
if (cacheValue != null) {
if ("NULL".equals(cacheValue)) {
return null; // 空值缓存,直接返回
}
return (Product) cacheValue;
}

// 查询数据库
Product product = productDao.findById(productId);
if (product != null) {
// 存在的数据,正常缓存
redisTemplate.opsForValue()
.set(cacheKey, product, Duration.ofHours(1));
} else {
// 不存在的数据,缓存空值(短过期时间)
redisTemplate.opsForValue()
.set(cacheKey, "NULL", Duration.ofMinutes(5));
}
return product;
}

优势:

  • 简单有效: 实现简单,效果明显
  • 防止穿透: 不存在的数据也会被缓存

问题:

  • 占用缓存空间: 空值也会占用缓存空间
  • 过期时间难以确定: 太短可能频繁重建,太长可能影响数据更新

四、改进方案2:布隆过滤器(Bloom Filter)

使用布隆过滤器快速判断数据是否存在:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class BloomFilterCache {
private BloomFilter<Long> bloomFilter;
private RedisTemplate<String, Object> redisTemplate;

@PostConstruct
public void init() {
// 创建布隆过滤器,预期100万数据,误判率1%
bloomFilter = BloomFilter.create(
Funnels.longFunnel(),
1000000,
0.01
);

// 预热布隆过滤器
List<Long> productIds = productDao.getAllProductIds();
productIds.forEach(bloomFilter::put);
}

public Product getProduct(Long productId) {
String cacheKey = "product:detail:" + productId + ":v1";

// 1. 布隆过滤器判断(快速失败)
if (!bloomFilter.mightContain(productId)) {
// 一定不存在,直接返回
return null;
}

// 2. 查询缓存
Product product = redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}

// 3. 查询数据库
product = productDao.findById(productId);
if (product != null) {
redisTemplate.opsForValue()
.set(cacheKey, product, Duration.ofHours(1));
// 新增商品,更新布隆过滤器
bloomFilter.put(productId);
} else {
// 不存在的数据也缓存
redisTemplate.opsForValue()
.set(cacheKey, "NULL", Duration.ofMinutes(5));
}
return product;
}
}

优势:

  • 内存占用小: 布隆过滤器的内存占用比缓存小得多
  • 性能高: 布隆过滤器的查询速度很快

问题:

  • 有误判率: 布隆过滤器可能将存在的数据判断为不存在(误判率可配置)
  • 实现复杂: 需要维护布隆过滤器

五、改进方案3:参数校验 + 限流

在查询前进行参数校验和限流:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public Product getProductWithValidation(Long productId) {
// 1. 参数校验
if (productId == null || productId <= 0) {
throw new IllegalArgumentException("无效的商品ID");
}

// 2. ID范围校验(假设商品ID不会超过1亿)
if (productId > 100000000L) {
return null;
}

// 3. 限流(使用Guava RateLimiter)
if (!rateLimiter.tryAcquire()) {
throw new RuntimeException("请求过于频繁");
}

// 4. 正常查询逻辑
return getProduct(productId);
}

优势:

  • 从源头防止: 在查询前就拦截无效请求
  • 保护系统: 限流可以防止恶意攻击

问题:

  • 需要维护规则: 需要维护参数校验规则
  • 可能误杀: 参数校验可能误杀正常请求

六、改进方案4: id使用雪花算法 + id校验

使用雪花算法生成的id作为表的主键,对传入的id进行校验,不符合雪花id的直接拒绝

优势 :

  • 雪花算法有包装好的工具 ,使用简单

2.3 缓存雪崩(Cache Avalanche)

一、问题定义与场景

缓存雪崩是指大量缓存同时失效,导致所有请求直接打到数据库。

具体场景:
电商大促,凌晨0点大量商品缓存同时过期(都设置了1小时过期),用户开始抢购,10万请求/秒直接打到数据库,导致系统崩溃。

为什么会发生:

  • 大量缓存设置了相同的过期时间
  • 过期时间到达时,所有缓存同时失效
  • 所有请求同时查询数据库,导致数据库压力骤增

二、初始方案:固定过期时间

最简单的做法是设置固定的过期时间:

1
2
3
4
5
6
7
public void cacheProduct(Product product) {
String cacheKey = "product:detail:" + product.getId() + ":v1";

// 所有商品都设置1小时过期,容易雪崩
redisTemplate.opsForValue()
.set(cacheKey, product, Duration.ofHours(1));
}

问题:
大量缓存同时失效,导致数据库压力骤增。


三、改进方案1:过期时间随机化

设置缓存过期时间时,基础时间+随机偏移:

1
2
3
4
5
6
7
8
9
10
public void cacheProductWithRandomExpire(Product product) {
String cacheKey = "product:detail:" + product.getId() + ":v1";

// 基础过期时间 + 随机偏移(1小时~1小时10分钟)
int baseExpire = 3600; // 1小时
int randomExpire = new Random().nextInt(600); // 0-10分钟随机

redisTemplate.opsForValue()
.set(cacheKey, product, Duration.ofSeconds(baseExpire + randomExpire));
}

优势:

  • 简单有效: 实现简单,效果明显
  • 防止雪崩: 避免大量缓存同时失效

问题:

  • 不能完全避免雪崩: 如果随机范围太小,仍然可能大量缓存同时失效
  • 过期时间难以确定: 随机范围需要根据具体场景调整

四、改进方案2:多级缓存

使用本地缓存(如Guava Cache)+ Redis缓存的多级缓存架构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class MultiLevelCache {
// 一级缓存:本地缓存(Guava Cache)
private LoadingCache<Long, Product> localCache = CacheBuilder
.newBuilder()
.maximumSize(10000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(new CacheLoader<Long, Product>() {
@Override
public Product load(Long productId) throws Exception {
// 本地缓存未命中,查Redis
return loadFromRedis(productId);
}
});

// 二级缓存:Redis
private Product loadFromRedis(Long productId) {
String cacheKey = "product:detail:" + productId + ":v1";
Product product = redisTemplate.opsForValue().get(cacheKey);

if (product != null) {
return product;
}

// Redis未命中,查数据库
product = productDao.findById(productId);
if (product != null) {
// 写入Redis
redisTemplate.opsForValue()
.set(cacheKey, product, Duration.ofHours(1));
}
return product;
}

public Product getProduct(Long productId) {
try {
// 先查本地缓存
return localCache.get(productId);
} catch (ExecutionException e) {
throw new RuntimeException("查询失败", e);
}
}
}

优势:

  • 性能最好: 本地缓存的访问速度比Redis更快
  • 减轻Redis压力: 本地缓存可以拦截大部分请求

问题:

  • 实现复杂: 需要维护两级缓存
  • 数据一致性难保证: 两级缓存的数据一致性难以保证

五、改进方案3:熔断降级

当数据库压力过大时,自动熔断,返回默认值或降级处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@HystrixCommand(
fallbackMethod = "getDefaultProduct",
commandProperties = {
@HystrixProperty(name = "execution.isolation.thread.timeoutInMilliseconds", value = "3000"),
@HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "10"),
@HystrixProperty(name = "circuitBreaker.errorThresholdPercentage", value = "50"),
@HystrixProperty(name = "circuitBreaker.sleepWindowInMilliseconds", value = "5000")
}
)
public Product getProductWithCircuitBreaker(Long productId) {
String cacheKey = "product:detail:" + productId + ":v1";

// 查询缓存
Product product = redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}

// 查询数据库
product = productDao.findById(productId);
if (product != null) {
redisTemplate.opsForValue()
.set(cacheKey, product, Duration.ofHours(1));
}
return product;
}

// 降级方法
public Product getDefaultProduct(Long productId, Throwable e) {
log.error("查询商品失败,返回默认值", e);

// 返回默认商品信息或提示
return new Product(productId, "商品信息加载中...", BigDecimal.ZERO);
}

优势:

  • 保护系统: 防止数据库被击垮
  • 保证可用性: 即使数据库故障,系统仍然可用

问题:

  • 用户体验下降: 返回默认值或降级处理,用户体验下降
  • 实现复杂: 需要引入熔断降级框架

六、改进方案4:缓存预热

系统启动时或大促前,提前将热点数据加载到缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Component
public class CachePreheater {

@PostConstruct
public void warmUpCache() {
// 系统启动时预热热点数据
List<Long> hotProductIds = getHotProductIds();
hotProductIds.forEach(productId -> {
Product product = productDao.findById(productId);
if (product != null) {
String cacheKey = "product:detail:" + productId + ":v1";
redisTemplate.opsForValue()
.set(cacheKey, product, Duration.ofHours(1));
}
});
}

// 大促前手动预热
public void preWarmForPromotion() {
List<Product> promotionProducts = getPromotionProducts();
promotionProducts.forEach(product -> {
String cacheKey = "product:detail:" + product.getId() + ":v1";
redisTemplate.opsForValue()
.set(cacheKey, product, Duration.ofHours(24)); // 大促期间24小时有效
});
}
}

优势:

  • 提前加载: 避免缓存未命中
  • 提升性能: 系统启动后立即可用

问题:

  • 需要预判热点: 需要提前预判哪些数据是热点
  • 占用缓存空间: 预热的数据会占用缓存空间

第四部分:分布式锁实战(一人一单)

需求: 每个用户只能参与一次秒杀活动,防止刷单。

问题:
在高并发下,同一个用户可能同时发起多个请求,导致创建多个订单。

解决方案:
使用分布式锁,锁的粒度是用户级别。

分布式锁优化

初始方案一:基础分布式锁

在秒杀场景下,最直观的做法是使用Redis的String实现分布式锁,保证同一时间只有一个线程可以扣减库存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public boolean seckill(Long activityId, Long productId, Long userId) {
String lockKey = "lock:seckill:" + activityId + ":" + productId;

// 获取分布式锁
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, userId.toString(), Duration.ofSeconds(10));

if (Boolean.TRUE.equals(locked)) {
try {
// 查询库存
Integer stock = redisTemplate.opsForValue()
.get("seckill:stock:" + activityId + ":" + productId);

if (stock != null && stock > 0) {
// 扣减库存
redisTemplate.opsForValue()
.decrement("seckill:stock:" + activityId + ":" + productId);

// 创建订单
createOrder(activityId, productId, userId);
return true;
}
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
}
return false;
}

问题1:锁过期时间难以确定

  • 时间太短: 业务未执行完,锁已过期,其他线程获取锁,导致重复执行
  • 时间太长: 业务执行完,锁未释放,其他线程长时间等待

问题2:锁误删
如果线程A执行过久导致锁过期了,线程B获取了锁。此时线程A执行完业务,会删除线程B的锁。

问题3:同一线程无法多次拿一把锁
该业务方法还需要调用另一个业务方法,如果另外一个方法也需要拿到这把锁,则会导致死锁风险,明明是同一个线程应该可以拿到,但是拿不到

问题4: 无法重试
获取锁失败直接就返回失败信息给前端,用户体验感很差,页面体现为一点击按钮几乎瞬间弹出失败,其次就是用户如果一失败就狂点,会导致过多请求发送,后端压力增大


问题1解决:看门狗机制(Watchdog)

问题场景:
业务执行时间不确定,锁过期时间难以设置。如果业务执行时间超过锁过期时间,锁会自动释放,导致其他线程获取锁。

看门狗机制:
在锁持有期间,定期延长锁的过期时间。

实现思路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class RedisLockWithWatchdog {
private static final long WATCHDOG_INTERVAL = 10000; // 10秒
private ScheduledExecutorService watchdogScheduler;

public boolean lock(String lockKey, String requestId, long expireTime) {
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, Duration.ofMillis(expireTime));

if (Boolean.TRUE.equals(locked)) {
// 启动看门狗
startWatchdog(lockKey, requestId, expireTime);
return true;
}
return false;
}

private void startWatchdog(String lockKey, String requestId, long expireTime) {
watchdogScheduler.scheduleAtFixedRate(() -> {
// 延长锁的过期时间
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('pexpire', KEYS[1], ARGV[2]) " +
"else return 0 end";

redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
requestId,
String.valueOf(expireTime)
);
}, 0, WATCHDOG_INTERVAL, TimeUnit.MILLISECONDS);
}

public void unlock(String lockKey, String requestId) {
// 停止看门狗
// 删除锁
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end";

redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
requestId
);
}
}

优势:

  • 自动延长锁的过期时间: 不需要预先设置很长的过期时间
  • 适应业务执行时间不确定的场景: 看门狗会自动延长锁的过期时间

问题:

  • 实现复杂: 需要维护看门狗线程
  • 资源消耗: 看门狗线程会占用系统资源

问题2解决: 记录线程id

实现思路:
在获取锁的时候,value的值设置为线程标识,在删除锁的需要先判断是不是value与当前线程是否相同,相同才能删除

优势:

  • 实现简单

问题:

  • ThreadLocal内存泄漏: 需要注意ThreadLocal的清理

问题3解决:可重入锁

问题场景:
同一个线程可能需要多次获取同一把锁。例如,一个方法调用另一个方法,两个方法都需要获取同一把锁。

基础锁的问题:
如果线程A已经持有锁,再次获取锁时会被阻塞,导致死锁。

可重入锁实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class RedisReentrantLock {
private ThreadLocal<Map<String, Integer>> lockCountMap = new ThreadLocal<>();

public boolean lock(String lockKey, String requestId, long expireTime) {
// 检查当前线程是否已经持有该锁
Map<String, Integer> countMap = lockCountMap.get();
if (countMap != null && countMap.containsKey(lockKey)) {
// 重入次数+1
countMap.put(lockKey, countMap.get(lockKey) + 1);
return true;
}

// 尝试获取锁
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, Duration.ofMillis(expireTime));

if (Boolean.TRUE.equals(locked)) {
// 记录重入次数
if (countMap == null) {
countMap = new HashMap<>();
lockCountMap.set(countMap);
}
countMap.put(lockKey, 1);
return true;
}
return false;
}

public void unlock(String lockKey, String requestId) {
Map<String, Integer> countMap = lockCountMap.get();
if (countMap != null && countMap.containsKey(lockKey)) {
// 重入次数-1
int count = countMap.get(lockKey) - 1;
if (count > 0) {
countMap.put(lockKey, count);
return;
}
// 重入次数为0,释放锁
countMap.remove(lockKey);
}

// 删除锁(需要验证requestId,防止误删)
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end";

redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
requestId
);
}
}

优势:

  • 支持同一个线程多次获取同一把锁: 避免死锁
  • 重入次数记录: 精确控制锁的释放

问题:

  • 实现复杂: 需要维护重入次数
  • ThreadLocal内存泄漏: 需要注意ThreadLocal的清理

改进方案四:可重试锁

问题场景:
获取锁失败时,直接返回失败,用户体验不好。希望可以重试获取锁。

可重试锁实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class RedisRetryableLock {
private static final long RETRY_INTERVAL = 100; // 100ms
private static final int MAX_RETRY_TIMES = 10;

public boolean lock(String lockKey, String requestId, long expireTime) {
for (int i = 0; i < MAX_RETRY_TIMES; i++) {
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, Duration.ofMillis(expireTime));

if (Boolean.TRUE.equals(locked)) {
return true;
}

// 等待一段时间后重试
try {
Thread.sleep(RETRY_INTERVAL);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return false;
}

public void unlock(String lockKey, String requestId) {
// 删除锁
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end";

redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
requestId
);
}
}

优势:

  • 提高锁获取成功率: 通过重试提高锁获取成功率
  • 用户体验更好: 不会立即返回失败

问题:

  • 增加系统负载: 频繁尝试获取锁会增加系统负载
  • 可能导致锁竞争激烈: 如果并发很高,重试会加剧锁竞争

终极方案五:Redisson分布式锁

Redisson提供了更强大的分布式锁实现,包括可重入锁、看门狗机制、可重试锁等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Autowired
private RedissonClient redissonClient;

public boolean seckillWithRedisson(Long activityId, Long productId, Long userId) {
// 获取分布式锁
RLock lock = redissonClient.getLock("lock:seckill:user:" + userId);

try {
// 尝试获取锁,最多等待10秒,锁持有10秒
boolean locked = lock.tryLock(10, 10, TimeUnit.SECONDS);

if (locked) {
try {
// 业务逻辑
Boolean participated = redisTemplate.opsForSet()
.isMember("seckill:user:" + activityId, userId);

if (Boolean.TRUE.equals(participated)) {
return false;
}

Integer stock = redisTemplate.opsForValue()
.get("seckill:stock:" + activityId + ":" + productId);

if (stock != null && stock > 0) {
redisTemplate.opsForValue()
.decrement("seckill:stock:" + activityId + ":" + productId);

redisTemplate.opsForSet()
.add("seckill:user:" + activityId, userId);

createOrder(activityId, productId, userId);
return true;
}
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}

return false;
}

Redisson的优势:

  • 内置看门狗机制: 自动延长锁的过期时间
  • 支持可重入锁: 同一个线程可以多次获取同一把锁
  • 支持公平锁、读写锁等多种锁类型: 满足不同场景需求
  • 提供丰富的API: 使用简单,功能强大

适用场景:

  • 生产环境: Redisson经过大量生产环境验证,稳定可靠
  • 复杂场景: 需要可重入、看门狗等高级特性的场景
  • 快速开发: 希望快速实现分布式锁的场景

总结与最佳实践

缓存设计检查清单

1. 数据选择

  • 是否读多写少?
  • 数据量是否适中(<1MB)?
  • 一致性要求是否可接受延迟?
  • 是否为热点数据或计算成本高?

2. Key设计

  • 是否遵循命名规范?
  • 是否包含业务前缀和版本号?
  • 是否考虑了数据维度(如语言、时间)?
  • 是否选择了合适的数据结构?

3. 过期策略

  • 是否设置了合理的过期时间?
  • 是否使用了随机过期防止雪崩?
  • 热点数据是否考虑永不过期或逻辑过期?
  • 是否有缓存预热机制?

4. 并发防护

  • 是否处理了缓存击穿?
  • 是否处理了缓存穿透?
  • 是否处理了缓存雪崩?
  • 秒杀等高并发场景是否使用了原子操作?

5. 数据一致性

  • 更新数据时是否同步更新/删除缓存?
  • 是否考虑了并发更新的竞态条件?
  • 是否使用了分布式锁或乐观锁?
  • 是否有异步更新机制?

方案选择指南

问题类型 推荐方案 适用场景 注意事项
数据不一致 先更新DB,再删缓存 一般场景 删除失败需重试
消息队列异步更新 高一致性要求 延迟较高
缓存击穿 互斥锁 一般热点数据 等待锁有延迟
逻辑过期 对一致性要求不高 实现复杂
永不过期 真正热点数据 需后台更新
缓存穿透 空值缓存 一般场景 占用缓存空间
布隆过滤器 大数据量 有误判率
缓存雪崩 过期时间随机化 所有场景 不能完全避免
多级缓存 高并发 实现复杂
分布式锁 Redisson 生产环境 功能强大
原生Redis 简单场景 需要自己实现看门狗