Redis核心实战:从缓存设计到分布式锁的深度解析
本文概览:本文从实际场景入手,系统讲解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查询。 - 数据量大时,即使有索引,排序操作仍很耗时。
- 每次都要从 MySQL 中
- 后果:排行榜加载慢,影响互动体验。
场景5:地理位置服务(如“附近的人”、“附近门店”)
- 无 Redis 时:
所有用户/门店的经纬度存储在 MySQL 中。
每次查询“附近 5 公里内的用户”,需执行类似:
1
2SELECT * 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作为版本号,便于数据结构变更时平滑升级
这种设计的好处是:
- 通过前缀可以快速识别数据类型,便于管理和监控
- 通过productId可以精确定位到具体商品
- 版本号支持数据结构升级,避免全量更新带来的风险
Value设计与数据结构选择:
商品详情是一个完整的对象,包含多个字段(名称、价格、库存、描述、图片等),适合使用String类型存储序列化后的JSON。虽然Hash也可以存储对象,但商品详情通常需要整体读取和更新,使用String更简单高效。
1 | { |
过期策略设计:
商品详情的过期时间需要平衡数据新鲜度和缓存命中率。设置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的优势在于:
- 可以按字段读取,节省带宽
- 更新单个字段不影响其他字段
- 内存占用比String更小
1 | HSET user:order:stats:1001:20260426:v1 \ |
过期策略设计:
订单统计数据相对稳定,一天内的数据不会变化。设置24小时的过期时间比较合适。可以在每天凌晨定时更新所有用户的统计数据,然后重新写入缓存。
2.3 热点数据:秒杀商品库存
业务特征:
秒杀活动是典型的高并发场景,每秒可能有数万次查询。库存数据量小,但实时性要求高,需要快速响应。这类数据的特点是并发极高、数据量小、实时性要求高。
Key设计思路:
1 | seckill:stock:{activityId}:{productId}:v1 |
seckill:stock:表明这是秒杀库存数据{activityId}标识具体活动{productId}标识具体商品:v1版本号
Value设计与数据结构选择:
库存是一个简单的整数,适合使用String类型存储。选择String而不是Hash的原因是:
- 库存只需要存储一个数字,不需要复杂结构
- String支持原子操作(INCR、DECR),适合高并发场景
- 内存占用最小
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的优势在于:
- 可以存储多个配置项,结构清晰
- 支持按字段读取和更新
- 内存占用比多个String更小
1 | HSET config:payment:alipay \ |
过期策略设计:
配置数据几乎不变化,可以设置较长的过期时间(如7天)或永不过期。为了保证配置更新的及时性,可以配合后台定时任务定期刷新缓存。
2.5 会话和临时数据:用户购物车
业务特征:
用户会话和购物车数据是临时性的,但访问频率很高。购物车需要支持商品的增删改查,会话数据需要支持用户登录状态的维护。
Key设计思路:
1 | cart:{userId}:v1 |
cart:或session:表明数据类型{userId}或{sessionId}标识具体用户或会话:v1版本号
Value设计与数据结构选择:
购物车包含多个商品和对应的数量,适合使用Hash数据结构。Hash的优势在于:
- 可以存储多个商品,结构清晰
- 支持按商品读取和更新
- 支持原子操作(HINCRBY),适合并发场景
1 | HSET cart:1001:v1 1001 2 # 商品1001,数量2 |
过期策略设计:
购物车数据是临时性的,可以设置7天的过期时间。用户长时间不访问购物车,数据自动清理,避免占用缓存空间。
三、Key设计最佳实践
3.1 分层命名规范
1 | {业务域}:{数据类型}:{标识符}:{维度}:{版本} |
示例:
product:detail:1001:v1- 商品详情user:order:list:1001:v1- 用户订单列表seckill:stock:2026042601:1001:v1- 秒杀库存
3.2 版本控制
当数据结构变更时,通过版本号平滑升级:
1 | // 旧版本 |
3.3 过期时间设计
- 固定过期: 适合一般数据(如1小时)
- 随机过期: 防止雪崩(1小时 + 0-10分钟随机)
- 永不过期: 热点数据,配合后台更新
- 逻辑过期: 需要平滑更新的场景
第三部分:缓存问题实战
一、数据不一致问题
例如: 缓存存储库存数量后,当库存数据库数据进行了更新操作,此时如果不对缓存进行更新或者删除,下次用户查询会直接得到缓存数据,此时缓存的数据为"假数据",与数据库的真实数据不一致,用户得到了错误的库存信息导致引发业务问题,这就称为数据不一致问题
1.1 缓存与数据库的最终一致性
一、初始方案:先删除缓存,再更新数据库
这个方案的思路很简单:先删除缓存,再更新数据库,这样可以避免查询到旧数据。
但是问题来了:
假设在高并发场景下:
- 线程A删除缓存
- 线程B查询缓存(未命中),查询数据库(得到旧数据)
- 线程A更新数据库(成功)
- 线程B将旧数据写入缓存
结果: 缓存中存储的是旧数据,出现数据不一致。
根本原因: 高并发下,线程B在缓存删除后、数据库更新前查询到了旧数据,并将旧数据写入缓存。
二、改进方案:先更新数据库,再删除缓存(Cache-Aside)
这个方案反过来:先更新数据库,再删除缓存。
看似解决了问题,但新的问题出现了:
在高并发场景下:
- 线程A查询缓存(未命中),查询数据库(得到旧数据)
- 线程B更新数据库(成功),删除缓存
- 线程A将旧数据写入缓存
结果: 缓存中仍然存储的是旧数据,数据不一致。
为什么会这样?
因为线程A在查询数据库后、写入缓存前,线程B已经更新了数据库并删除了缓存。但此时缓存已经被删除,线程A的写入操作会将旧数据重新写入缓存。
这种问题发生的概率:
虽然这种情况发生的概率较低(需要线程A查询数据库后、写入缓存前,线程B完成更新和删除操作),但在高并发场景下,仍然可能发生。
三、进一步改进:延迟双删 + TTL兜底
为了解决上述问题,引入延迟双删策略:
- 先删除缓存
- 更新数据库
- 延迟一段时间(如500ms)
- 再次删除缓存
- 新写入的缓存设置一个TTL,保证即使延迟双删出现问题,错误的缓存数据也不会长时间存在,导致用户长期接受错误数据
原理:
延迟的目的是等待所有可能的并发查询完成。第一次删除是为了防止旧数据被查询到,第二次删除是为了清理可能被并发查询写入的脏数据。
但是问题依然存在:
- 延迟时间难以确定: 太短可能清理不干净,太长影响性能
- 第二次删除可能失败: 如果第二次删除失败,仍然会有脏数据
- 增加系统复杂度: 需要引入延迟机制
延迟时间的选择:
延迟时间应该大于查询数据库和写入缓存的时间。通常设置为500ms-1s,但这个时间需要根据具体业务场景调整。
四、终极方案:消息队列异步更新
将缓存更新操作异步化:
- 更新数据库
- 发送消息到消息队列
- 消费者异步删除/更新缓存
优势:
- 解耦: 数据库操作和缓存操作分离,互不影响
- 可靠性: 消息队列保证消息不丢失,缓存更新失败可以重试
- 削峰填谷: 消息队列可以缓冲突发的缓存更新请求
缺点:
- 延迟较高: 消息队列的延迟通常在几十毫秒到几百毫秒
- 实现复杂: 需要引入消息队列,增加系统复杂度
- 成本较高: 需要维护消息队列,增加运维成本
适用场景:
- 对一致性要求极高的场景
- 更新频率不高的场景
- 可以接受一定延迟的场景
1.2 业务操作的原子性与正确性
一、初始方案:先查后减(非原子操作)
在秒杀场景下,最直观的做法是先查询库存,判断库存是否充足,然后扣减库存:
1 | // 查询库存 |
问题场景:
假设库存为1,两个用户同时发起秒杀请求:
- 用户A查询库存,得到1
- 用户B查询库存,得到1
- 用户A判断库存充足,扣减库存,库存变为0
- 用户B判断库存充足,扣减库存,库存变为-1
结果: 库存从1变成了-1,但实际上应该只扣减1次,库存应该为0。这就是超卖问题。
根本原因: 查询库存和扣减库存不是原子操作,多个线程可以同时读取到相同的库存值。
二、改进方案二:Lua脚本
将查询、判断、扣减操作打包成原子操作:
1 | local stock = tonumber(redis.call('GET', KEYS[1])) |
原理:
Lua脚本在Redis中是原子执行的,整个脚本执行期间不会被其他命令打断。
优势:
- 原子性: 整个业务逻辑原子执行,不会出现超卖
- 灵活性: 可以处理复杂的业务逻辑
局限性:
- 需要编写和维护Lua脚本: 增加了开发和维护成本
- 调试困难: Lua脚本的调试比Java代码困难
三、改进方案3:CAS(Compare And Set)乐观锁(若无购买次数限制问题,该方案最好)
- 版本号方案
1 | // 数据库表增加version字段 |
原理:
每次更新时检查version版本号,如果版本号不匹配,说明数据已经被其他线程修改,更新失败。优势:
- 不需要加锁: 性能好,适合读多写少的场景
- 简单易用: 只需要在SQL中增加version条件
缺点:
- 更新失败需要重试: 如果更新失败,需要重试,增加了复杂度
- 不适合写多的场景: 如果并发写入很高,重试次数会很多
- 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.1 缓存击穿(Cache Breakdown)
一、问题定义与场景
缓存击穿是指热点Key过期的瞬间,大量并发请求同时访问该Key,导致所有请求都打到数据库。
具体场景:
电商大促,爆款商品"iPhone 15"的缓存过期,10000个用户同时访问,所有请求都查询数据库,导致数据库压力骤增。
为什么会发生:
- 热点Key设置了固定的过期时间
- 过期时间到达时,所有请求同时发现缓存失效
- 所有请求同时查询数据库,导致数据库压力骤增
二、源头处理 : 如果采取了设置TTL的方案,在数据缓存的TTL设置加上随机数,避免同时过期
三、初始方案:无防护
最简单的做法是什么都不做,缓存过期后直接查询数据库:
1 | public Product getProduct(Long productId) { |
问题:
缓存过期瞬间,所有请求都查询数据库,导致数据库压力骤增。
四、改进方案1:互斥锁(Mutex Lock)
当缓存失效时,使用分布式锁保证只有一个线程可以查询数据库并重建缓存:
1 | public Product getProductWithLock(Long productId) { |
优势:
- 简单有效: 实现简单,效果明显
- 保证一致性: 只有一个线程可以重建缓存
问题:
- 等待锁的线程会有延迟: 用户体验下降
- 锁竞争激烈时性能下降: 如果并发很高,锁竞争会很激烈
五、改进方案2:逻辑过期(Logical Expire)
缓存中存储逻辑过期时间,应用层判断是否过期。过期后异步重建缓存,同时返回旧数据:
1 |
|
优势:
- 响应快: 即使逻辑过期,也能立即返回旧数据
- 用户体验好: 用户不会感知到缓存重建的过程
问题:
- 实现复杂: 需要维护逻辑过期时间
- 有短暂的数据不一致: 逻辑过期后,返回的是旧数据
六、 改进方案3:热点数据永不过期
真正的热点数据设置永不过期,通过后台定时任务定期更新:
1 | // 每5分钟更新一次 |
优势:
- 性能最好: 不会有缓存失效的问题
- 用户体验最好: 始终有缓存可用
问题:
- 需要维护后台任务: 增加了系统复杂度
- 数据可能延迟: 后台任务更新频率有限,数据可能延迟
2.2 缓存穿透(Cache Penetration)
一、问题定义与场景
缓存穿透是指查询不存在的数据,由于缓存中没有,每次都查询数据库。
具体场景:
- 恶意攻击: 攻击者构造大量不存在的商品ID(如999999999),每次查询都穿透到数据库
- 业务场景: 用户查询已下架的商品,或者传入无效的参数
- 参数错误: 前端传入错误的商品ID,导致查询不存在的数据
为什么会发生:
- 查询的数据在数据库中不存在
- 缓存中没有存储空值
- 每次查询都穿透到数据库
二、初始方案:无防护
最简单的做法是什么都不做,查询不到数据就不缓存:
1 | public Product getProduct(Long productId) { |
问题:
查询不存在的数据时,每次都查询数据库,导致数据库压力增大。
三、改进方案1:空值缓存(Null Cache)
查询数据库发现数据不存在时,将空值缓存起来,设置较短的过期时间:
1 | public Product getProductWithNullCache(Long productId) { |
优势:
- 简单有效: 实现简单,效果明显
- 防止穿透: 不存在的数据也会被缓存
问题:
- 占用缓存空间: 空值也会占用缓存空间
- 过期时间难以确定: 太短可能频繁重建,太长可能影响数据更新
四、改进方案2:布隆过滤器(Bloom Filter)
使用布隆过滤器快速判断数据是否存在:
1 | public class BloomFilterCache { |
优势:
- 内存占用小: 布隆过滤器的内存占用比缓存小得多
- 性能高: 布隆过滤器的查询速度很快
问题:
- 有误判率: 布隆过滤器可能将存在的数据判断为不存在(误判率可配置)
- 实现复杂: 需要维护布隆过滤器
五、改进方案3:参数校验 + 限流
在查询前进行参数校验和限流:
1 | public Product getProductWithValidation(Long productId) { |
优势:
- 从源头防止: 在查询前就拦截无效请求
- 保护系统: 限流可以防止恶意攻击
问题:
- 需要维护规则: 需要维护参数校验规则
- 可能误杀: 参数校验可能误杀正常请求
六、改进方案4: id使用雪花算法 + id校验
使用雪花算法生成的id作为表的主键,对传入的id进行校验,不符合雪花id的直接拒绝
优势 :
- 雪花算法有包装好的工具 ,使用简单
2.3 缓存雪崩(Cache Avalanche)
一、问题定义与场景
缓存雪崩是指大量缓存同时失效,导致所有请求直接打到数据库。
具体场景:
电商大促,凌晨0点大量商品缓存同时过期(都设置了1小时过期),用户开始抢购,10万请求/秒直接打到数据库,导致系统崩溃。
为什么会发生:
- 大量缓存设置了相同的过期时间
- 过期时间到达时,所有缓存同时失效
- 所有请求同时查询数据库,导致数据库压力骤增
二、初始方案:固定过期时间
最简单的做法是设置固定的过期时间:
1 | public void cacheProduct(Product product) { |
问题:
大量缓存同时失效,导致数据库压力骤增。
三、改进方案1:过期时间随机化
设置缓存过期时间时,基础时间+随机偏移:
1 | public void cacheProductWithRandomExpire(Product product) { |
优势:
- 简单有效: 实现简单,效果明显
- 防止雪崩: 避免大量缓存同时失效
问题:
- 不能完全避免雪崩: 如果随机范围太小,仍然可能大量缓存同时失效
- 过期时间难以确定: 随机范围需要根据具体场景调整
四、改进方案2:多级缓存
使用本地缓存(如Guava Cache)+ Redis缓存的多级缓存架构:
1 | public class MultiLevelCache { |
优势:
- 性能最好: 本地缓存的访问速度比Redis更快
- 减轻Redis压力: 本地缓存可以拦截大部分请求
问题:
- 实现复杂: 需要维护两级缓存
- 数据一致性难保证: 两级缓存的数据一致性难以保证
五、改进方案3:熔断降级
当数据库压力过大时,自动熔断,返回默认值或降级处理:
1 |
|
优势:
- 保护系统: 防止数据库被击垮
- 保证可用性: 即使数据库故障,系统仍然可用
问题:
- 用户体验下降: 返回默认值或降级处理,用户体验下降
- 实现复杂: 需要引入熔断降级框架
六、改进方案4:缓存预热
系统启动时或大促前,提前将热点数据加载到缓存:
1 |
|
优势:
- 提前加载: 避免缓存未命中
- 提升性能: 系统启动后立即可用
问题:
- 需要预判热点: 需要提前预判哪些数据是热点
- 占用缓存空间: 预热的数据会占用缓存空间
第四部分:分布式锁实战(一人一单)
需求: 每个用户只能参与一次秒杀活动,防止刷单。
问题:
在高并发下,同一个用户可能同时发起多个请求,导致创建多个订单。
解决方案:
使用分布式锁,锁的粒度是用户级别。
分布式锁优化
初始方案一:基础分布式锁
在秒杀场景下,最直观的做法是使用Redis的String实现分布式锁,保证同一时间只有一个线程可以扣减库存:
1 | public boolean seckill(Long activityId, Long productId, Long userId) { |
问题1:锁过期时间难以确定
- 时间太短: 业务未执行完,锁已过期,其他线程获取锁,导致重复执行
- 时间太长: 业务执行完,锁未释放,其他线程长时间等待
问题2:锁误删
如果线程A执行过久导致锁过期了,线程B获取了锁。此时线程A执行完业务,会删除线程B的锁。
问题3:同一线程无法多次拿一把锁
该业务方法还需要调用另一个业务方法,如果另外一个方法也需要拿到这把锁,则会导致死锁风险,明明是同一个线程应该可以拿到,但是拿不到
问题4: 无法重试
获取锁失败直接就返回失败信息给前端,用户体验感很差,页面体现为一点击按钮几乎瞬间弹出失败,其次就是用户如果一失败就狂点,会导致过多请求发送,后端压力增大
问题1解决:看门狗机制(Watchdog)
问题场景:
业务执行时间不确定,锁过期时间难以设置。如果业务执行时间超过锁过期时间,锁会自动释放,导致其他线程获取锁。
看门狗机制:
在锁持有期间,定期延长锁的过期时间。
实现思路:
1 | public class RedisLockWithWatchdog { |
优势:
- 自动延长锁的过期时间: 不需要预先设置很长的过期时间
- 适应业务执行时间不确定的场景: 看门狗会自动延长锁的过期时间
问题:
- 实现复杂: 需要维护看门狗线程
- 资源消耗: 看门狗线程会占用系统资源
问题2解决: 记录线程id
实现思路:
在获取锁的时候,value的值设置为线程标识,在删除锁的需要先判断是不是value与当前线程是否相同,相同才能删除
优势:
- 实现简单
问题:
- ThreadLocal内存泄漏: 需要注意ThreadLocal的清理
问题3解决:可重入锁
问题场景:
同一个线程可能需要多次获取同一把锁。例如,一个方法调用另一个方法,两个方法都需要获取同一把锁。
基础锁的问题:
如果线程A已经持有锁,再次获取锁时会被阻塞,导致死锁。
可重入锁实现:
1 | public class RedisReentrantLock { |
优势:
- 支持同一个线程多次获取同一把锁: 避免死锁
- 重入次数记录: 精确控制锁的释放
问题:
- 实现复杂: 需要维护重入次数
- ThreadLocal内存泄漏: 需要注意ThreadLocal的清理
改进方案四:可重试锁
问题场景:
获取锁失败时,直接返回失败,用户体验不好。希望可以重试获取锁。
可重试锁实现:
1 | public class RedisRetryableLock { |
优势:
- 提高锁获取成功率: 通过重试提高锁获取成功率
- 用户体验更好: 不会立即返回失败
问题:
- 增加系统负载: 频繁尝试获取锁会增加系统负载
- 可能导致锁竞争激烈: 如果并发很高,重试会加剧锁竞争
终极方案五:Redisson分布式锁
Redisson提供了更强大的分布式锁实现,包括可重入锁、看门狗机制、可重试锁等。
1 |
|
Redisson的优势:
- 内置看门狗机制: 自动延长锁的过期时间
- 支持可重入锁: 同一个线程可以多次获取同一把锁
- 支持公平锁、读写锁等多种锁类型: 满足不同场景需求
- 提供丰富的API: 使用简单,功能强大
适用场景:
- 生产环境: Redisson经过大量生产环境验证,稳定可靠
- 复杂场景: 需要可重入、看门狗等高级特性的场景
- 快速开发: 希望快速实现分布式锁的场景
总结与最佳实践
缓存设计检查清单
1. 数据选择
- 是否读多写少?
- 数据量是否适中(<1MB)?
- 一致性要求是否可接受延迟?
- 是否为热点数据或计算成本高?
2. Key设计
- 是否遵循命名规范?
- 是否包含业务前缀和版本号?
- 是否考虑了数据维度(如语言、时间)?
- 是否选择了合适的数据结构?
3. 过期策略
- 是否设置了合理的过期时间?
- 是否使用了随机过期防止雪崩?
- 热点数据是否考虑永不过期或逻辑过期?
- 是否有缓存预热机制?
4. 并发防护
- 是否处理了缓存击穿?
- 是否处理了缓存穿透?
- 是否处理了缓存雪崩?
- 秒杀等高并发场景是否使用了原子操作?
5. 数据一致性
- 更新数据时是否同步更新/删除缓存?
- 是否考虑了并发更新的竞态条件?
- 是否使用了分布式锁或乐观锁?
- 是否有异步更新机制?
方案选择指南
| 问题类型 | 推荐方案 | 适用场景 | 注意事项 |
|---|---|---|---|
| 数据不一致 | 先更新DB,再删缓存 | 一般场景 | 删除失败需重试 |
| 消息队列异步更新 | 高一致性要求 | 延迟较高 | |
| 缓存击穿 | 互斥锁 | 一般热点数据 | 等待锁有延迟 |
| 逻辑过期 | 对一致性要求不高 | 实现复杂 | |
| 永不过期 | 真正热点数据 | 需后台更新 | |
| 缓存穿透 | 空值缓存 | 一般场景 | 占用缓存空间 |
| 布隆过滤器 | 大数据量 | 有误判率 | |
| 缓存雪崩 | 过期时间随机化 | 所有场景 | 不能完全避免 |
| 多级缓存 | 高并发 | 实现复杂 | |
| 分布式锁 | Redisson | 生产环境 | 功能强大 |
| 原生Redis | 简单场景 | 需要自己实现看门狗 |






