1. 三种模式怎么选:先把缓存的位置摆正
聊 Redis 缓存的三种设计模式,绕不开 Cache Aside、Read/Write Through、Write Behind 这三个名字。但说实话,光背这三个词没什么用,我见过太多团队把“用了缓存”当成性能指标,结果上线后数据对不上、库存超卖、用户看到已删除的内容还在页面上挂着,最后回头查半天,问题往往不在 Redis 本身,而在于当初根本没想清楚缓存放在这场读写链路里的哪个位置、由谁负责和数据库对齐。
我自己的判断标准很朴素:缓存设计模式的本质,是回答“谁在什么时候、以什么顺序去碰数据库”这个问题。业务代码自己管数据库、缓存只是旁边的一层加速层,那是 Cache Aside;业务代码只跟缓存打交道、缓存层自己负责落库,那是 Read/Write Through;业务代码写完缓存就返回、落库交给后台异步冲刷,那是 Write Behind。三种模式在吞吐、一致性、实现复杂度上是一条明显的滑尺,你往左边挪一点,一致性就更强、吞吐就更低,往右挪则反过来。
这篇内容适合谁看?如果你是刚开始接触 Redis 缓存、写业务代码时被“更新数据库还是先删缓存”绕晕的同学,这篇会把三条链路拆开讲清楚,并给出可以直接抄的代码;如果你已经在线上跑着缓存、遇到过数据不一致但又说不清根因,第 5 章和第 6 章的参数计算与排查表应该对你更有用。全文基于我在几个模拟项目里的实践整理,其中的业务场景均为虚构代称,代码是示意实现,参数需要结合你自己的压测结果调整,不要照搬数字。
1.1 缓存到底解决什么问题,又会带来什么新问题
先把收益和代价摆平。缓存带来的收益很直接:把原本要打到数据库的读请求拦在内存里,把响应时间从毫秒级压到亚毫秒级,把数据库的连接数和 CPU 占用降下来。一个典型的读多写少场景,比如某内容平台的文章详情页,读请求可能是写请求的几十倍甚至上百倍,加一层缓存之后数据库的 QPS 压力能降一个数量级。
但缓存不是白拿的。它引入了第二份数据,而两份数据就必然存在时间窗口内的不一致。这个不一致窗口有多长、能不能被业务容忍,才是选模式的真正依据。金融账户余额、库存扣减这类场景,窗口哪怕几十毫秒也可能出事故;而文章阅读数、商品评论列表这种,几秒钟的延迟用户根本感知不到,那就完全可以用更激进的模式换吞吐。
还有一个常被忽略的代价:缓存让故障面变大了。原来只有数据库一个依赖,现在多了一个 Redis,它可能超时、可能被限流、可能内存打满触发淘汰、可能主从切换丢数据。所以真正成熟的写法里,缓存层永远是“可降级”的——Redis 挂了,业务降级回源数据库,慢但能活,而不是整个接口 500。
注意:在动手选模式之前,先问自己三个问题——这份数据的读写比是多少?不一致窗口业务能容忍多久?缓存整体不可用时,业务能不能降级回源?这三个答案基本就把模式定下来了。
1.2 三种模式的本质区别与选型判断
用一句话把三种模式的区别说透:
| 模式 | 数据流向 | 一致性强度 | 吞吐能力 | 实现复杂度 | 典型场景 |
|---|---|---|---|---|---|
| Cache Aside | 业务代码分别操作 DB 和缓存 | 中(有短暂窗口) | 中高 | 低 | 绝大多数业务读缓存 |
| Read/Write Through | 业务只操作缓存,缓存层同步落库 | 较高 | 中 | 中高 | 缓存与存储强绑定的中间件场景 |
| Write Behind | 业务只写缓存,异步批量落库 | 低(有丢失风险) | 极高 | 高 | 计数、日志、点赞、行为埋点 |
Cache Aside 是绝大多数系统的默认答案,因为它最“土”也最可控:业务代码知道自己在干什么,出问题好排查。Read/Write Through 的关键特征是缓存层屏蔽了数据库,业务侧看不到 DB 调用,所有同步落库逻辑收敛在缓存组件内部,好处是业务代码干净、一致性由统一入口保证,坏处是这个组件要自己写或者依赖成熟的缓存框架,出了问题排查链路更长。Write Behind 则是拿一致性换吞吐,写请求全部落在内存,后台按批次刷进数据库,适合那些“丢一两条也不致命、但写入量极大”的数据。
下面三章我会逐个拆开讲,每一章都会回答同一个问题:这条链路上,数据在哪个瞬间可能不一致,以及怎么把这个窗口压到业务能接受的范围。
2. Cache Aside 旁路缓存:九成业务系统的默认答案
Cache Aside 也叫旁路缓存,名字里的“旁路”指的是缓存位于数据库旁边,业务代码是那个同时操作两边的人。读的时候先查缓存,命中就返回,没命中就查数据库然后回填缓存;写的时候先更新数据库,再让缓存失效(删除,而不是更新)。
这套流程看起来平淡无奇,但魔鬼全在细节里。我见过最常见的错误写法是“更新数据库 + 更新缓存”,看起来更直觉,实际上问题更多:两个并发写请求,A 先更新 DB 再更新缓存,B 后更新 DB 再更新缓存,如果因为网络抖动导致 B 的缓存更新先落地、A 的后落地,缓存里就留下了 A 的旧值,而且这个旧值会一直待在那儿直到过期。改成删除缓存之后,这个“后写覆盖先写”的概率大幅下降,因为删除是幂等的,谁先谁后结果都一样(都是不存在)。
2.1 读链路:命中、回填与空值兜底
读链路的骨架只有四步,但每一步都有坑。
- 查缓存,命中直接返回。
- 未命中,查数据库。
- 数据库有值,写回缓存并设置过期时间。
- 数据库也没值,写一个空值标记进缓存,过期时间设短一些。
第 4 步是很多新手会漏掉的。如果有人拿一个不存在的 ID 疯狂请求,每次都穿透到数据库,这就是典型的缓存穿透。空值缓存的处理方式很直接:把null或者一个特殊标记(比如字符串"__NULL__")写进缓存,TTL 给个 30 到 120 秒。为什么不能给太长?因为如果这个 ID 后来真的被创建了,空值缓存会让它在一段时间内查不到,业务上表现为“我刚创建的数据查不到”,这是很典型的客诉来源。
空值缓存的 TTL 怎么定,我的经验是按“业务能容忍的新数据可见延迟”反推。比如某后台系统要求新建数据 1 分钟内必须能查到,那就把空值 TTL 定在 60 秒以内。同时要给空值缓存加一个总量上限,防止有人用随机 ID 把内存打爆,比如限制这类 key 最多占缓存总量的 5%,超出就走布隆过滤器兜底。
2.2 写链路:为什么是“先更新库,再删缓存”
这个顺序不是拍脑袋定的。假设反过来,先删缓存再更新数据库,会有一个经典的并发窗口:
- 线程 A 删除缓存,准备更新数据库。
- 线程 B 来读,缓存未命中,读到数据库里的旧值,回填缓存。
- 线程 A 完成数据库更新。
结果缓存里是旧值,数据库是新值,而且这个旧值会一直存在到 TTL 到期。这个窗口在“先更新库再删缓存”的方案下要小得多,因为删除动作发生在数据库提交之后,缓存里最多存在“数据库已更新、缓存还没删”的极短时间。
但请注意,这只是“概率更小”,不是“绝对没有”。真正极端的并发下依然可能出问题:读请求在缓存失效的瞬间回源,读到了旧值,此时写请求完成了数据库更新并删除了缓存,然后读请求把旧值回填了进去。这个窗口通常只有几毫秒,但如果你面对的是强一致要求的场景,就必须上延迟双删或者更重的方案。
2.3 延迟双删:把回填的旧值再删一次
延迟双删的思路很朴素:更新数据库之后删一次缓存,等一小段时间再删一次,把这段时间内可能被其他线程回填的旧值清掉。
关键在“等多久”。这个时间要覆盖两件事:一是主从复制的延迟(如果你的读请求打到从库),二是读请求回源加回填的耗时。我的做法是先压测拿到读接口回源链路的 P99 耗时,再乘以 2 作为基准值,然后加上主从复制的延迟观测值。举个具体的例子:读接口回源 P99 是 40 毫秒,主从延迟观测在 10 毫秒以内,那延迟时间取 100 毫秒左右就够,没必要上 500 毫秒甚至 1 秒。
延迟双删的第二个删除动作建议放到异步线程池或者延迟队列里执行,不要在主写链路里sleep。在主链路里睡,等于把写接口的 RT 直接拉长了 100 毫秒,高峰期线程池很快会被打满。用延迟队列(比如基于 Redis 的 ZSet 做延时任务,或者消息队列的延迟消息)更稳妥。
// 写链路示意:先更新库,再删缓存,再异步延迟删一次 public void updateArticle(Article article) { // 1. 更新数据库(事务内完成) articleMapper.updateById(article); // 2. 立即删除缓存 String key = "article:detail:" + article.getId(); redisTemplate.delete(key); // 3. 投递延迟删除任务,100ms 后再删一次 delayQueue.offer(new DelayTask(key, 100, TimeUnit.MILLISECONDS)); }2.4 删除失败怎么办:重试与兜底
删除缓存这个动作是会失败的。Redis 抖动、网络超时、连接池打满,都可能让删除落空。如果删除失败又没有兜底,缓存里的旧值就会一直留到 TTL 到期,对于 TTL 设得很长(比如 24 小时)的 key,这就是一次长时间的数据不一致。
我的处理方式是两层保险。第一层是有限次重试,删除失败后立刻重试 2 到 3 次,间隔 50 到 100 毫秒,大部分抖动都能扛过去。第二层是消息队列补偿,把删除失败的 key 和一个版本号投递到消息队列,由消费者持续重试,直到成功或者达到重试上限后告警。
这里有个细节值得说:补偿消息里最好带上数据版本号或者更新时间戳。消费者拿到消息后,先读一下数据库里这条记录的当前版本,如果版本比消息里的新,说明已经有更新的写请求处理过了,这条补偿消息可以直接丢弃,避免用过期消息把新缓存删掉又引发一次回源。这个设计在多写并发下能省掉很多莫名其妙的“缓存被无故删除”问题。
3. Read/Write Through:把缓存当主存储来用
Read/Write Through 的核心思想是业务代码只认缓存,不认数据库。读的时候调缓存的读接口,缓存未命中由缓存层自己去数据库加载;写的时候调缓存的写接口,缓存层自己负责同步写数据库并更新缓存。业务侧看起来,缓存就是那个“唯一的数据源”。
这个模式在业务代码层面确实干净,不需要在每个 Service 里重复写“查缓存-回源-回填”这一套。代价是这个缓存层的实现要自己扛,或者依赖成熟框架里提供的类似能力(比如一些 ORM 框架的二级缓存就是类似的思路)。自己写的话,工作量不算小,而且一旦缓存层出 bug,排查起来比旁路模式难得多——因为业务代码里看不到数据库调用,链路被藏起来了。
3.1 读穿透:未命中时由缓存层同步加载
读穿透的流程是:缓存层收到读请求,命中直接返回;未命中时,缓存层调用配置好的加载器(Loader)去数据库查,查到后写入缓存并返回。
这里最关键的设计是Loader 的抽象。加载器需要知道“这个 key 对应哪张表、用什么条件查、查出来怎么序列化”。常见做法是按实体类型注册加载器,key 的命名里带上实体标识,比如user:{id}对应用户加载器。加载器内部还要做几件事:空值处理(查不到也要缓存空标记)、并发控制(同一个 key 的加载要合并,避免缓存击穿)、异常处理(数据库异常时不能把异常当成空值缓存起来)。
加载器的并发合并特别值得说。如果同一个热点 key 失效,瞬间几十个请求同时未命中,每个都去查数据库,这就是缓存击穿。Read/Write Through 模式下这个问题的处理责任在缓存层,通常用一个 key 级别的互斥锁,只有拿到锁的线程去加载,其余线程短暂等待后重读缓存。
def read_through(key, loader, ttl=300): value = redis.get(key) if value is not None: return deserialize(value) lock_key = f"lock:{key}" # 拿锁,超时时间按回源 P99 的 2~3 倍设置 if redis.set(lock_key, "1", nx=True, ex=3): try: value = loader() # 回源数据库 if value is None: redis.set(key, NULL_MARK, ex=60) # 空值短 TTL else: redis.set(key, serialize(value), ex=ttl) return value finally: redis.delete(lock_key) else: # 没拿到锁,短暂等待后重读缓存 time.sleep(0.05) return read_through(key, loader, ttl)3.2 写穿透:同步落库的代价在哪
写穿透的流程是:业务调缓存的写接口,缓存层先(或后)写数据库,再更新或失效缓存,整个过程同步完成才返回。
同步落库带来的直接后果是写接口的 RT 等于数据库写 RT 加缓存操作 RT,没有任何折扣。这意味着写穿透在写密集场景下并不比直接写数据库快多少,它的价值主要体现在“业务代码干净”和“一致性由统一入口保证”上,而不是性能。
那它适合什么场景?我总结下来是两类。一类是读远多于写、且实体关系相对固定的系统,比如配置中心、字典表、权限数据这类,写操作很少,绝大部分是读,读穿透能省掉大量重复的回源代码。另一类是缓存层和存储层需要严格绑定的中间件场景,比如自己封装的通用数据访问层,所有业务都走它,一致性策略统一在这一层实现,比让每个业务团队各写一套要可靠得多。
不适合的场景也很明确:高并发写入的计数、订单状态流转这类,写穿透的同步链路会成为瓶颈,而且每次写都要同步碰数据库,等于把缓存的吞吐优势抵消掉了。
3.3 一个容易忽略的坑:写穿透下的事务边界
写穿透模式下,缓存层内部同时操作了数据库和缓存,这两者不在同一个事务里。如果数据库写成功、缓存更新失败,缓存里就是旧值。如果数据库写失败、缓存却已经更新了,缓存里就是一条数据库里不存在的数据,这个更严重。
解决办法是把缓存更新放在数据库事务提交之后,并且缓存更新失败时回滚不了数据库,只能标记为“脏 key”投递补偿。另外,缓存层内部不要开跨库事务,那是自找麻烦。我的做法是:数据库事务内只做数据库的事,事务提交成功后通过一个提交后回调(afterCommit)去更新或删除缓存,失败就进补偿队列。这套逻辑在旁路模式里也是通用的,只是旁路模式下这段代码由业务自己写,写穿透下由缓存层统一写。
注意:不管哪种模式,都不要在数据库事务未提交时去操作缓存。事务还没提交,别的线程读缓存读到的可能是你马上要回滚的数据,这种“脏读进缓存”的问题排查起来极其痛苦。
4. Write Behind 写回:吞吐量放大器与它的代价
Write Behind 也叫 Write Back,思路是:写请求只写缓存,立刻返回成功,缓存层在后台按一定策略把变更批量刷进数据库。这一下就把写接口的 RT 从“数据库写耗时”压到了“内存写耗时”,吞吐能有数量级的提升。
代价也很直白:数据在刷盘之前只存在于内存里,Redis 挂了或者进程崩了,这段数据就没了。另外,如果同一条记录在短时间内被改了很多次,只有最后一次会真正落库(这其实是好事,减少了写放大),但如果业务需要保留每一次变更的历史,写回模式就不合适。
我在几个模拟项目里用过写回,都是那种“丢一点无所谓”的场景:文章的阅读数、点赞数、用户行为埋点、接口调用统计。这类数据即使丢了最近几百毫秒的量,业务上也完全能接受,而它们恰恰是写入量最大、最不值得为每条都去碰一次数据库的部分。
4.1 写回的执行流程拆解
完整的写回链路大概是这样:
- 写请求到达,更新 Redis 里的对应字段(比如
HINCRBY article:stat:{id} read_count 1)。 - 同时把这次变更记录进一个待刷新的队列(可以用 Redis 的 List 或者 ZSet,按 key 去重)。
- 后台线程按固定间隔(比如 100 毫秒)或者按累积条数(比如 200 条)触发一次冲刷。
- 冲刷时批量读取待刷新 key 的当前值,组装成批量更新语句写进数据库。
- 写库成功后再从待刷新队列里移除这些 key;失败则保留,下个周期重试。
这里有个设计要点:待刷新队列里存 key,不存具体值。因为同一条记录可能被改了很多次,队列里存值的话要么产生大量冗余,要么需要复杂的合并逻辑。存 key 的话,冲刷时直接读缓存里的最新值就行,天然做到了“只落最终值”。
4.2 数据丢失窗口有多大,怎么算
丢失窗口 = 冲刷间隔 + 重试等待时间。假设冲刷间隔 100 毫秒,失败重试 3 次、每次间隔 200 毫秒,那最坏情况下一条数据在内存里待了大约 700 毫秒还没落库,这期间进程挂掉,这条数据就丢了。
这个窗口能不能接受,取决于业务。对于阅读数这类,700 毫秒的丢失量可能就几条,无所谓。对于订单金额这类,一毫秒都不能丢,那就根本不该用写回。
除了进程崩溃,还有两个风险点要提前想好。一是Redis 内存打满触发淘汰,待刷新的 key 被淘汰掉,数据就静默丢失了,这个比崩溃更隐蔽。所以写回场景下的 Redis 一定要设置noeviction或者把待刷新数据的 key 加上保护前缀、监控淘汰量。二是主从切换丢数据,异步复制下主节点挂了,最近的写入可能还没同步到从节点,所以写回模式不要依赖主从切换来做高可用兜底。
4.3 落地时必须补上的四件事
第一件是幂等。冲刷失败重试时,同一条更新可能被执行两次。如果用的是SET count = 100这种覆盖式写法,重复执行没问题;如果用的是count = count + 1,重试就会多加。所以冲刷语句要写成基于最终值的覆盖,或者带上版本号做乐观锁。
第二件是批量大小控制。一次冲刷的批量太大,单条 SQL 会很长,数据库解析压力大、锁持有时间长;批量太小,又起不到合并写入的效果。我的经验是单批 200 到 500 条,单条 SQL 的大小控制在几百 KB 以内,具体按数据库的max_allowed_packet反推。
第三件是冲刷与业务读的一致性。如果业务读走的是数据库,那在冲刷之前读到的是旧值;如果业务读走缓存,读到的是新值。同一个接口里两种读法混用,就会出现“刚写完刷新页面,列表里还是旧数字”的诡异现象。解决办法是同一个业务域内的读,统一走缓存优先。
第四件是优雅停机时的强制冲刷。进程收到停止信号时,先把待刷新队列里的数据全部刷完再退出,能挽回大部分丢失。这一步很多人会漏,加上的成本很低,收益却很实在。
5. 实操:把三种模式跑起来的完整流程与参数
这一章把前面讲的东西落到具体操作上。我用一个虚构的“模拟项目”来说明:一个内容详情服务,文章主体数据读多写少,用 Cache Aside;文章的阅读数、点赞数写多读多且能容忍短暂不一致,用 Write Behind;后台的字典配置读极多写极少,用 Read/Write Through 的思路封装。三种模式在同一个系统里并存,各自解决不同的问题。
5.1 数据模型与 Key 设计
先把 key 规范定下来,这是后面所有参数计算的基础。
| 数据类型 | Key 格式 | 结构 | TTL | 说明 |
|---|---|---|---|---|
| 文章详情 | article:detail:{id} | String(JSON) | 30 分钟 + 随机抖动 | Cache Aside |
| 空值标记 | article:detail:{id} | String(__NULL__) | 60 秒 | 防穿透 |
| 回源互斥锁 | lock:article:{id} | String | 3 秒 | 防击穿 |
| 阅读计数 | article:stat:{id} | Hash | 不过期(靠 LRU 淘汰) | Write Behind |
| 待刷新队列 | stat:pending | ZSet | 不过期 | 按 score 去重 |
| 字典配置 | dict:{type} | String(JSON) | 2 小时 | 读穿透 |
TTL 为什么加随机抖动?因为如果一大批 key 在同一时刻被批量写入、TTL 又完全相同,它们会在同一秒集体失效,全量打到数据库,这就是缓存雪崩。抖动的幅度取基础 TTL 的 5% 到 10%,比如 30 分钟的基础 TTL,抖动范围就是 0 到 180 秒。
内存估算也得提前做。假设文章详情平均 2 KB,热点文章 20 万篇,那光详情缓存就是 400 MB 左右,加上 key 本身和 Redis 内部开销(通常按 value 大小的 1.2 到 1.5 倍估算),预留 600 MB 比较稳妥。计数类数据假设 100 万篇文章、每篇一个 Hash 含 3 个字段,每个 Hash 约 200 字节,合计约 200 MB。加起来一个实例 1 GB 左右,这个量级单实例完全扛得住,不需要急着上集群。
5.2 Cache Aside 的完整实现:互斥锁加逻辑过期
先看回源互斥锁。锁的超时时间怎么定?按回源链路的 P99 耗时乘以 3 来设。比如回源 P99 是 800 毫秒(包含数据库查询和 JSON 序列化),那锁超时设 3 秒比较合适。设太短,回源还没结束锁就自动释放了,互斥失效;设太长,持锁线程一旦挂掉,其他线程要等很久。3 秒这个值是基于“绝大多数回源都能在 1 秒内完成”的经验判断。
public Article getArticle(Long id) { String key = "article:detail:" + id; String cached = redisTemplate.opsForValue().get(key); if (cached != null) { if ("__NULL__".equals(cached)) { return null; // 命中空值标记,直接返回,保护数据库 } return JSON.parseObject(cached, Article.class); } String lockKey = "lock:article:" + id; boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS); if (locked) { try { // 双重检查,防止等锁期间已被其他线程回填 cached = redisTemplate.opsForValue().get(key); if (cached != null) { return "__NULL__".equals(cached) ? null : JSON.parseObject(cached, Article.class); } Article article = articleMapper.selectById(id); if (article == null) { redisTemplate.opsForValue() .set(key, "__NULL__", 60, TimeUnit.SECONDS); } else { long ttl = 1800 + ThreadLocalRandom.current().nextLong(180); redisTemplate.opsForValue() .set(key, JSON.toJSONString(article), ttl, TimeUnit.SECONDS); } return article; } finally { redisTemplate.delete(lockKey); } } else { // 没抢到锁,短暂等待后重试读缓存,避免所有请求都去回源 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getArticle(id); } }互斥锁方案在极热点场景下有个副作用:锁竞争激烈时,没抢到锁的线程都在sleep重试,会占用大量工作线程。如果某个 key 的 QPS 特别高(比如首页 banner),建议改用逻辑过期方案:缓存里存的是{data, expireAt},物理上不设 TTL 或者设一个很长的 TTL;读到数据后判断expireAt是否已过,过期则异步起一个线程去刷新,当前请求先返回旧数据。这样所有请求都能立刻拿到数据,只有刷新是异步的,代价是刷新期间会读到旧值。
5.3 穿透、击穿、雪崩的防护参数怎么算
这三个词经常被混着用,其实防护手段完全不同,参数也不同。
缓存穿透针对的是“查不存在的数据”。除了空值缓存,还可以上布隆过滤器做前置拦截。布隆过滤器的误判率公式是p ≈ (1 - e^(-kn/m))^k,其中 n 是预期元素数量、m 是位数组长度、k 是哈希函数个数。实际调参时不用自己算,按“给 100 万条数据、要求误判率 1%”来估,位数组大概 1 MB 出头,3 到 7 个哈希函数都行。要注意布隆过滤器只能判断“一定不存在”和“可能存在”,删除数据后它没法同步移除,所以适合数据只增不改不删的场景。
缓存击穿针对的是“单个热点 key 失效瞬间的大量并发”。防护参数就是互斥锁的超时时间和等待重试的间隔,前者按 P99 乘 3 定,后者一般 20 到 50 毫秒。
缓存雪崩针对的是“大量 key 同时失效或 Redis 整体不可用”。前者靠 TTL 抖动解决,抖动幅度取基础 TTL 的 5% 到 10%;后者靠降级和限流,Redis 不可用时业务回源数据库,同时在数据库前面加一层并发限制,比如用信号量把回源并发控制在 50 以内,超出的请求短暂等待或直接返回兜底数据,避免数据库被打穿。
注意:TTL 抖动只对“批量写入”有效。如果你的 key 是分散写入的,它们的过期时间天然就分散,无需额外抖动;但如果你的缓存是用定时任务批量重建的,那必须加抖动,否则每天凌晨重建完,第二天凌晨就是雪崩时刻。
5.4 Write Behind 的批量冲刷参数
冲刷的两个触发条件:时间间隔和累积条数,满足其一就触发。
时间间隔的定法是从丢失窗口反推。假设业务能接受最长 1 秒的数据丢失,那冲刷间隔最好不要超过 300 毫秒,给重试留出余量。我一般设 100 到 200 毫秒。
累积条数的定法是从批处理效率反推。数据库批量更新的性能拐点通常在单批 500 到 1000 条之间(取决于单条数据宽度),我一般设 200 条,兼顾延迟和效率。
def flush_pending_stats(batch_size=200): # 从待刷新 ZSet 里取最早的一批 key keys = redis.zrange("stat:pending", 0, batch_size - 1) if not keys: return updates = [] for key in keys: article_id = key.split(":")[-1] stat = redis.hgetall(key) if not stat: redis.zrem("stat:pending", key) continue updates.append({ "id": article_id, "read_count": int(stat.get("read_count", 0)), "like_count": int(stat.get("like_count", 0)), }) try: # 基于最终值的覆盖式更新,天然幂等,重试安全 db.batch_upsert_article_stat(updates) redis.zrem("stat:pending", *keys) except Exception as e: log.warn("flush failed, will retry next round: %s", e) # 不移除,下个周期自动重试这里用 ZSet 而不是 List,是为了让同一个 key 多次写入时能靠 score 去重。比如 score 用当前时间戳,每次写入时先zadd更新 score,同一个 member 只会保留一份,天然实现了“合并重复写入”。冲刷时按 score 从小到大取,先进先出,避免某些 key 被饿死。
还有一个细节:更新语句必须是基于最终值覆盖,不能用增量。因为冲刷读的是缓存里的当前绝对值,如果写成SET read_count = read_count + ?,重试的时候就会多算一次。用INSERT ... ON DUPLICATE KEY UPDATE read_count = VALUES(read_count)这类写法,重复执行结果一致。
6. 常见问题与排查技巧实录
缓存相关的问题有个共同特点:现象很诡异,日志里看不出啥,但原因往往就那几类。下面这张表是我自己整理的速查表,遇到问题先对号入座。
6.1 高频问题速查表
| 现象 | 最可能的根因 | 排查手段 | 处理方式 |
|---|---|---|---|
| 刚更新的数据查不到新值 | 写后没删缓存,或删了但被旧值回填 | 查缓存内容和数据库对比,看 TTL | 加延迟双删,补偿队列带上版本号 |
| 数据永久不一致,重启才恢复 | 删除缓存失败且无兜底,TTL 又很长 | 搜删除失败的日志,看 TTL 设置 | 加重试和补偿,TTL 加上限 |
| 某个 key 的 QPS 打满数据库 | 热点 key 失效,击穿 | 看回源日志的 key 分布 | 加互斥锁或改逻辑过期 |
| 凌晨数据库负载突增 | 批量 key 同时过期,雪崩 | 看缓存命中率曲线是否断崖 | TTL 加随机抖动,错峰重建 |
| 大量不存在 ID 的请求打到库 | 缓存穿透 | 看回源请求的 ID 命中率 | 空值缓存加布隆过滤器 |
| 计数类数据偶尔少一点 | 写回模式丢了未刷新的数据 | 查冲刷失败日志、Redis 淘汰量 | 缩短冲刷间隔,改 noeviction |
| 缓存内存涨得很快 | 空值缓存或大 key 堆积 | MEMORY USAGE抽样看大 key | 限制空值数量,拆分大 key |
| 主从切换后读到旧数据 | 读请求打到延迟较大的从库 | 对比主从数据版本 | 关键读走主库,或加版本校验 |
6.2 几个只有踩过才知道的坑
第一个坑:以为删了缓存就万事大吉,其实删错了 key。有次排查一个“用户改了昵称但页面没变”的问题,查了半天代码逻辑都对,最后发现缓存 key 里拼的是旧的用户 ID 格式,删除时用的新格式,两个 key 根本不是同一个。这类问题在 key 拼接逻辑散落在多个地方时特别容易发生。我的做法是把 key 的生成收敛到一个工具类里,所有地方统一调用,禁止手写字符串拼接。
第二个坑:序列化方式不一致导致读出来的数据是乱码或者反序列化失败。同一个 key,写的时候用 JDK 序列化,读的时候用 JSON,或者升级了实体类字段但缓存里还是旧结构。缓存里存 JSON 时,实体类加字段要保证向后兼容,删字段要谨慎,最稳的做法是给缓存结构带上版本号,比如article:detail:v2:{id},结构变更时直接换版本号,旧的自然过期。
第三个坑:在事务里删缓存,事务回滚了缓存却已经被删。这个前面提过,但值得再说一遍,因为它太常见了。表现是“什么都没改成功,缓存却被清了,然后一堆请求去回源”。用afterCommit回调或者事务同步器能解决,成本很低。
第四个坑:压测时 Redis 好好的,上线后疯狂超时。十有八九是连接池配置的问题。默认连接池可能只有 8 个连接,高并发下大量请求在等连接。经验值是最大连接数按“单实例 QPS × 平均 RT(秒)”来估,比如单实例 2000 QPS、平均 RT 1 毫秒,那理论上 2 个连接就够,但考虑到突发和阻塞,实际配 32 到 64 更稳。
第五个坑:用KEYS *排查问题,直接把线上 Redis 卡住。KEYS是 O(N) 的阻塞命令,几百万 key 的实例上执行一次能卡住好几秒,期间所有请求超时。排查大 key 用SCAN配合MEMORY USAGE,排查模式匹配用SCAN MATCH,别用KEYS。这个坑几乎每个团队都踩过一次。
第六个坑:写回模式下,读接口读缓存、列表接口读数据库,两边数字对不上。用户刚点完赞,详情页显示 101,刷新一下列表页显示 100,体验极差。解决办法是同一个业务域内的读统一走缓存,或者列表页的数字也从缓存聚合,别混着来。
我个人在实际操作中的体会是,缓存的复杂度从来不在 Redis 这一侧,而在“业务代码怎么和它配合”这一侧。三种模式里,Cache Aside 是最值得先吃透的,因为它把一致性的责任明确摆在业务代码面前,逼着你想清楚每个写操作的顺序和失败兜底;Read/Write Through 和 Write Behind 更像是把这份责任收归到某一层,用封装换取一致性策略的统一,但前提是你真的有能力把那层封装写对。如果团队规模不大、业务还在快速迭代,老老实实用 Cache Aside,把 key 规范、TTL 抖动、空值缓存、删除补偿这四件事做扎实,已经能覆盖九成以上的问题。剩下那一成,等真的遇到了、并且能说清楚“不一致窗口有多大、业务能不能忍”,再去考虑换模式也不迟。