Java避坑案例 - 高并发场景下的分布式缓存策略
Redis - 缓存设计深度解析:穿透、并发、雪崩与热点策略
深入理解分布式技术 - 先更新数据库,还是先更新缓存
架构思维:读缓存 - 减少数据库读操作压力
某系统商品详情页因叠加推荐、成交记录、优惠活动等功能,导致每次访问需执行数十条SQL,平均响应时间从3.61秒恶化至15.53秒。初期考虑本地缓存(如Guava),但测算发现5万商品数据需750GB内存(30节点×25GB),成本过高。最终采用分布式缓存方案,将数据集中存储(如Redis),所有服务节点共享同一缓存源,避免冗余存储,显著提升访问速度至毫秒级,同时降低硬件成本。优化后,异步加载非核心数据(如成交记录)进一步减轻实时查询压力。
技术选型完成后,开始考虑缓存的一些具体问题,先从缓存何时存储数据入手
这种逻辑唯一麻烦的地方是,当用户发来大量的并发请求时,它们会发现缓存中没有数据,那么所有请求会同时挤在第2)步,此时如果这些请求全部从数据库读取数据,就会让数据库崩溃。
数据库的崩溃可以分为3种情况。
1)单一数据过期或者不存在,这种情况称为缓存击穿。
解决方案:第一个线程如果发现Key不存在,就先给Key加锁,再从数据库读取数据保存到缓存中,最后释放锁。如果其他线程正在读取同一个Key值,那么必须等到锁释放后才行。关于锁的问题前面已经讲过,此处不再赘述。
2)数据大面积过期或者Redis宕机,这种情况称为缓存雪崩。
解决方案:设置缓存的过期时间为随机分布或设置永不过期即可。
3)一个恶意请求获取的Key不在数据库中,这种情况称为缓存穿透。
比如正常的商品ID是从100000到1000000(10万到100万之间的数值),那么恶意请求就可能会故意请求2000000以上的数据。这种情况如果不做处理,恶意请求每次进来时,肯定会发现缓存中没有值,那么每次都会查询数据库,虽然最终也没在数据库中找到商品,但是无疑给数据库增加了负担。这里给出两种解决办法。
①在业务逻辑中直接校验,在数据库不被访问的前提下过滤掉不存在的Key。
②针对恶意请求的Key存放一个空值在缓存中,防止恶意请求骚扰数据库。
故,总结如下:
缓存击穿(单一Key失效)
缓存雪崩(大量Key同时失效或Redis宕机)
缓存穿透(恶意查询不存在的数据)
null
或占位符,并设置较短过期时间。上面这些逻辑都是在确保查询数据的请求已经过来后如何适当地处理,如果缓存数据找不到,再去数据库查询,最终是要占用服务器额外资源的。那么最理想的就是在用户请求过来之前把数据都缓存到Redis中。这就是缓存预热。
其具体做法就是在深夜无人访问或访问量小的时候,将预热的数据保存到缓存中,这样流量大的时候,用户查询就无须再从数据库读取数据了,将大大减小数据读取压力。
故,总结如下:
关于缓存何时存数据的问题就讨论完了,接下来开始讨论更新缓存的问题,这部分内容因涉及双写(缓存+数据库),所以会花费一些篇幅。
在缓存更新时,我们需要考虑 数据库与缓存的一致性,同时避免 并发问题 和 性能瓶颈。以下是 5种常见的缓存更新策略,分析它们的优缺点,并给出推荐方案。
对于这个组合,会遇到这种情况:假设第二步更新数据库失败了,要求回滚缓存的更新,这时该怎么办呢?Redis不支持事务回滚,除非采用手工回滚的方式,先保存原有数据,然后再将缓存更新回原来的数据,这种解决方案有些缺陷。
这里简单举个例子。
1)原来缓存中的值是a,两个线程同时更新库存。
2)线程A将缓存中的值更新成b,且保存了原来的值a,然后更新数据库。
3)线程B将缓存中的值更新成c,且保存了原来的值b,然后更新数据库。
4)线程A更新数据库时失败了,它必须回滚,那现在缓存中的值更新成什么呢?理论上应该更新成c,因为数据库中的值是c,但是,线程A里面无从获得c这个值。
如果在线程A更新缓存与数据库的整个过程中,先把缓存及数据库都锁上,确保别的线程不能更新,是否可行?当然是可行的。但是其他线程能不能读取?
假设线程A更新数据库失败回滚缓存时,线程C也加入进来,它需要先读取缓存中的值,这时又返回什么值?
看到这个场景,是不是有点儿熟悉?不错,这就是典型的事务隔离级别场景。所以就不推荐这个组合,因为此处只是需要使用一下缓存,而这个组合就要考虑事务隔离级别的一些逻辑,成本太大。接着考虑别的组合。
故, 总结如下:
流程:
问题:
b
,线程B更新缓存为 c
,最终缓存可能是 b
或 c
,而数据库可能是另一个值。结论:❌ 不推荐,容易导致数据不一致。
使用这种方案,即使更新数据库失败了也不需要回滚缓存。这种做法虽然巧妙规避了失败回滚的问题,却引出了两个更大的问题。
1)假设线程A先删除缓存,再更新数据库。在线程A完成更新数据库之前,后执行的线程B反而超前完成了操作,读取Key发现没有数据后,将数据库中的旧值存放到了缓存中。线程A在线程B都完成后再更新数据库,这样就会出现缓存(旧值)与数据库的值(新值)不一致的问题。
2)为了解决一致性问题,可以让线程A给Key加锁,因为写操作特别耗时,这种处理方法会导致大量的读请求卡在锁中。
以上描述的是典型的高可用和一致性难以两全的问题,如果再加上分区容错就是CAP(一致性Consistency、可用性Availability、分区容错性Partition Tolerance)了,这里不展开讨论,接下来继续讨论另外3种组合
故, 总结如下:
流程:
问题:
解决方案:
结论:❌ 不推荐,容易导致缓存与数据库不一致。
对于组合3,同样需要考虑两个问题。
1)假设第一步(更新数据库)成功,第二步(更新缓存)失败了怎么办?
因为缓存不是主流程,数据库才是,所以不会因为更新缓存失败而回滚第一步对数据库的更新。此时一般采取的做法是重试机制,但重试机制如果存在延时还是会出现数据库与缓存不一致的情况,不好处理。
2)假设两个线程同时更新同一个数据,线程A先完成了第一步,线程B先完成了第二步怎么办?线程A把值更新成a,线程B把值更新成b,此时数据库中的最新值是b,因为线程A先完成了第一步,后完成第二步,所以缓存中的最新值是a,数据库与缓存的值还是不一致,这个逻辑还是有问题的。
因此,也不建议采用这个组合
故, 总结如下:
流程:
问题:
a
,线程B更新DB为 b
→ 线程B先更新缓存为 b
,线程A后更新缓存为 a
,导致缓存是 a
,而DB是 b
。结论:❌ 不推荐,仍可能不一致。
针对组合4,先看看它能不能解决组合3的第二个问题。
假设两个线程同时更新同一个数据,线程A先完成第一步,线程B先完成第二步怎么办?
线程A把值更新成a,线程B把值更新成b,此时数据库中的最新值是b,因为线程A先完成了第一步,所以第二步谁先完成已经不重要了,因为都是直接删除缓存数据。这个问题解决了。
那么,它能解决组合3的第一个问题吗?假设第一步成功,第二步失败了怎么办?
这种情况的出现概率与组合3相比明显低不少,因为删除比更新容易多了。虽然这个组合方案不完美,但出现一致性问题的概率较低。
故, 总结如下:
流程:
优点:
问题:
解决方案:
结论:✅ 推荐,相比前3种方案更可靠。
除了组合3会碰到的问题,组合4还会碰到别的问题吗?
是的。假设线程A要更新数据,先完成第一步更新数据库,在线程A删除缓存之前,线程B要访问缓存,那么取得的就是旧数据。这是一个小小的缺陷。
那么,以上问题有办法解决吗?
还有一个方案,就是先删除缓存,再更新数据库,再删除缓存。这个方案其实和先更新数据库,再删除缓存差不多,因为还是会出现类似的问题:假设线程A要更新数据库,先删除了缓存,这一瞬间线程C要读缓存,先把数据迁移到缓存;然后线程A完成了更新数据库的操作,这一瞬间线程B也要访问缓存,此时它访问到的就是线程C放到缓存里面的旧数据。
不过组合5出现类似问题的概率更低,因为要刚好有3个线程配合才会出现问题(比先更新数据库,再删除缓存的方案多了一个需要配合的线程)。
但是相比于组合4,组合5规避了第二步删除缓存失败的问题——组合5是先删除缓存,再更新数据库,假设它的第三步“再删除缓存”失败了,也没关系,因为缓存已经删除了。
其实没有一个组合是完美的,它们都有读到脏数据(这里指旧数据)的可能性,只不过概率不同。根据以上分析,组合5相对来说是比较好的选择。
不过这个组合也有一些问题要考虑,具体如下。
任何一个方案都不是完美的,但如果剩下1%的问题需要花好几倍的代价去解决,从技术上来讲得不偿失,这就要求架构师去说服业务方,去平衡技术的成本和收益。
故, 总结如下:
流程:
优点:
问题:
适用场景:
结论:✅ 最佳实践,比方案4更可靠。 延迟双删通过两次删除操作建立安全窗口,在工程实践上实现了性能与一致性的最佳平衡,是分布式系统缓存更新的首选方案 .
方案 | 描述 | 一致性 | 推荐度 |
---|---|---|---|
1️⃣ 先更新缓存,再更新DB | 易回滚问题 | ❌ 差 | ❌ 不推荐 |
2️⃣ 先删缓存,再更新DB | 旧数据问题 | ❌ 差 | ❌ 不推荐 |
3️⃣ 先更新DB,再更新缓存 | 并发问题 | ⚠️ 一般 | ❌ 不推荐 |
4️⃣ 先更新DB,再删缓存 | 较可靠 | ✅ 较好 | ⭐ 推荐 |
5️⃣ 延迟双删 | 最可靠 | ✅ 最佳 | ⭐⭐⭐ 最佳 |
没有完美方案,但 方案4和5 在大多数场景下能平衡 性能与一致性。
负载均衡(读扩展)
数据分片(写扩展)
数据冗余(容灾)
故障自动转移(Failover)
一致性保证
推荐架构:
核心监控指标
keyspace_hits / (keyspace_hits + keyspace_misses)
,低于80%需优化缓存策略。used_memory
,避免超过最大内存(maxmemory
)触发淘汰。slowlog
分析耗时命令(如KEYS *
、大Value操作)。redis-cli --latency
,超过1ms需排查网络或阻塞命令。connected_clients
,防止连接泄漏。开源监控工具
自研监控建议
INFO
命令输出的关键指标。最终建议:根据业务规模选择Redis Sentinel或Cluster,并配套监控告警体系,确保缓存稳定支撑业务高峰。