1. 从一次线上事故说起:缓存和数据库为什么总是“打架”
这张标题我盯了很久,因为“缓存与数据库不一致”几乎是每个PHP开发者在业务量上去之后都会撞上的墙。先讲个我亲历的场景:某个电商项目的商品详情页,Redis里存了一份商品库存和价格信息,代码逻辑是读缓存、没命中就查MySQL然后回填。某天运营批量改价,后台接口更新了MySQL,缓存却还是老价格,用户端看到的价格和结算价对不上,客诉直接爆了。排查时发现更新缓存的代码在异常分支里没执行,MySQL事务回滚了,Redis里的旧值却一直没人管——这就是最典型的缓存与数据库不一致。
这个问题之所以值得“详解”,是因为它不像报错那样有明确的堆栈,而是隐蔽的、间歇性的、数据层面的错乱。它不挑框架,ThinkPHP、Laravel、原生PDO都逃不掉;不挑缓存中间件,Redis、Memcached一样中招;甚至不挑业务复杂度,简单一个计数器都可能翻车。很多人以为加个缓存就完事了,实际上缓存引入的那一刻,一致性问题就跟着来了,只是业务量小、并发低的时候没暴露而已。
这篇内容我打算从更新策略的底层逻辑讲起,把几种常见方案(Cache Aside、延迟双删、串行化、版本号、消息队列异步刷)逐一拆开,分析它们各自解决了什么、留下了什么坑,再分享我在实际项目中怎么组合落地、怎么排查线上不一致。无论你是刚接触缓存的初级开发,还是被线上数据错乱折磨过的老手,这里面都应该有你用得上的东西。
2. 为什么会出现不一致:先弄清缓存的“写入时机”困境
2.1 缓存不是数据库的影子,而是另一份数据
很多人对缓存有一个错误认知:觉得缓存是数据库的“只读副本”,只要数据库改了,缓存必然会跟着对。实际上在大多数PHP项目里,缓存和数据库是两套独立的存储系统,Redis和MySQL之间没有任何自动同步机制。所有的一致性,全靠业务代码里那一行行“先更新谁、再删谁、出错了怎么办”的逻辑来维持。
我们把数据操作拆开看,一个完整的数据变更流程无非两条路。第一条是“先写库、再操作缓存”,第二条是“先操作缓存、再写库”。每条路都有死穴。先写库、再更新缓存,如果在更新缓存那一刻Redis连接超时,数据库已经提交了,缓存还是旧值;先更新缓存、再写库,又可能数据库事务失败回滚,缓存里却已经写入了新值。两条路都无法用单次操作保证一致性,这是分布式系统里典型的“跨存储事务”问题,MySQL和Redis之间没有分布式事务协议,靠应用层协调本身就很难做完美。
更麻烦的是并发。同一个商品的读请求和写请求可能同时到达,如果没有锁或队列约束,两个请求的先后顺序在数据库层和缓存层可能完全颠倒。数据库里写入的是A值,缓存里后到的请求写的是B值,两边就彻底对不上了。这种问题在压测时最容易复现,人肉测试几乎看不出来,因为它需要精确的时间窗口。
2.2 Cache Aside模式:业界的默认选择,也是问题的高发区
绝大多数PHP项目的缓存策略都是Cache Aside,也叫旁路缓存。逻辑很简单:读的时候先查缓存,没命中就查库,然后把结果塞进缓存;写的时候先更新数据库,然后删除缓存(或者更新缓存)。这个模式好理解,效率也高,但它有两个天然缺陷。
第一个缺陷是“删除缓存失败的沉默风险”。数据库更新成功,但Redis的DEL命令因为网络抖动失败了。这种情况下没有任何报错,因为数据库才是业务的主存储,接口返回的是成功,日志也不会记录Redis删除失败这件事。缓存里躺着旧数据,直到它过期或者被手动清理。我见过很多线上不一致事故,根因就是Redis在某一次删除时超时,然后旧数据在缓存里存活了好几个小时。
第二个缺陷是并发窗口。假设一个读请求在缓存里没有命中,于是去数据库查到了旧值A,这个时候一个写请求进来把数据库改成了B并删除了缓存,然后那个读请求把自己查到的旧值A写回了缓存。结果:数据库是B,缓存是A。这个窗口理论上非常小,但压测时多线程跑起来,出现的概率并不低。有人管这个叫“缓存击穿式的不一致”,本质上是读请求的旧值回填覆盖了写请求的删除动作。
2.3 更新还是删除:为什么我建议“删缓存”而不是“改缓存”
还有一个很多新手纠结的问题:写完数据库之后,缓存应该直接更新成新值,还是干脆删除让下次读的时候再回填?我的建议是,除非业务有强实时性要求,否则一律用删除。
直接更新缓存有个隐患:如果更新操作本身出了问题(比如序列化失败、数据类型不对),缓存可能变成一个残缺的值;而且如果两个写请求并发到达,后到的先执行,先到的后执行,缓存里最终留下的可能是旧值。删除缓存就简单多了,删掉之后最多多一次数据库查询,顶多算性能小损失,但不会留下错误数据。
当然删除也有个问题:如果紧接着就有大量读请求涌入,缓存被穿透,数据库压力会瞬间变大,这就是另一个话题“缓存击穿”了。解决方式是加互斥锁或者用空值缓存兜底,这个我后面会详细讲。
3. 解决不一致的主流方案拆解:从简单到可靠
3.1 延迟双删:最经典的PHP落地姿势
延迟双删应该是国内PHP圈子里流传最广的解法,流程是:先更新数据库,删除缓存,然后睡一小会儿(比如500毫秒),再删除一次缓存。逻辑很直接:第一次删除是为了清掉旧值,睡一会儿是为了让并发读请求把旧值回填进缓存的动作完成,第二次删除就是把这些回填的脏数据再清掉。
这个方案的优点是实现成本极低,不需要引入额外组件,几行代码就能搞定。我经常在项目里看到类似这样的代码:
public function updateGoodsPrice($goodsId, $newPrice) { $db = $this->getDb(); $db->beginTransaction(); try { $db->update('goods', ['price' => $newPrice], ['id' => $goodsId]); $db->commit(); } catch (\Exception $e) { $db->rollBack(); throw $e; } // 第一次删除 $this->redis->del('goods:price:' . $goodsId); // 睡500ms,等待并发读请求回填 usleep(500000); // 第二次删除 $this->redis->del('goods:price:' . $goodsId); }但延迟双删的坑也很明显。首先,这个“睡一觉”对性能有影响,每次写操作都要阻塞500毫秒,高并发下完全不可接受。其次,500ms只是经验值,如果某个读请求因为GC停顿、网络延迟或慢查询,回填动作超过了这个时间窗口,第二次删除提前执行完,脏数据还是会留下来。所以延迟双删又叫“尽力而为的最终一致性”,它不是数学证明,只是工程妥协。
实际项目中我做了两个改进。一是把固定sleep改为分层延迟:第一次立即删除,第二次延迟1秒删除,第三次再延迟5秒删除(通过异步任务或消息队列执行),这样覆盖的时间窗口更宽。二是把sleep放进异步队列或定时任务里执行,不在用户请求链路里阻塞。比如用PHP的Swoole协程或ThinkPHP的队列任务,推送一个“延迟删除缓存”的任务即可,主流程只保留第一次删。
3.2 串行化处理:用队列和锁把并发变成排队
延迟双删的薄弱点在于它无法真正消除并发竞态,只是把不一致的时间窗口缩小了。如果业务对一致性要求很高(比如库存、余额这种不能差一分钱的场景),就需要做串行化处理。
串行化的核心思路:对同一个数据项的读写操作,全部放到同一个队列里按顺序执行,或者用分布式锁保证同一时刻只有一个线程在操作这个数据项。这样数据库和缓存的更新就变成了严格的先后动作,不会出现你方唱罢我登场的交错。
在PHP里实现串行化有几种常见方式。第一种是Redis分布式锁,更新数据前先获取锁,拿到锁的线程按“先更新库、再删缓存”的顺序执行,其他线程自旋等待。锁的过期时间要设置合理,比如业务操作平均耗时100ms,锁的超时时间设300ms,既防止死锁又不至于频繁抢锁失败。第二种是消息队列,把写操作封装成任务发到队列,消费者单线程拉取并执行,天然就是串行的。第三种是数据库层面的行锁(SELECT ... FOR UPDATE),把同一行数据的读写操作串行化,但这对读多写少的场景不太友好,容易造成读阻塞。
我个人的建议是:大部分业务用分布式锁就够,没有必要上消息队列,消息队列适合那种写操作本身很重、需要异步化的场景。而且分布式锁要小心“锁误删”的问题——线程A获取锁,因为某些原因执行超时,锁自动过期;线程B获取同一个锁,开始处理;线程A执行完毕,释放锁,把线程B的锁给删了。解决方式是在锁的value里存一个唯一标识(如UUID),释放前检查是不是自己的。这个坑我踩过不止一次,代码里必须写上。
3.3 版本号机制:把一致性问题交给数据自身
版本号机制是一个比较“优雅”的解法,逻辑是:数据库表加一个version字段,每次更新时version自增;缓存里也存一份version值;读取缓存时如果发现缓存的version小于数据库当前的version,说明缓存过期,主动失效并回源。
这个方案的优点是不需要sleep、不需要锁,一致性的判断在读取侧完成,天然支持高并发。但它也有代价:每次读缓存都要查一次数据库的version(或者用Redis存储version并在更新时同步),实际上是把“读缓存”变成了“读缓存+读版本号”,性能损耗要看具体场景。另外它只能解决“新旧版本”的问题,解决不了“二分叉”并发写的问题——两个写请求同时提交,数据库里最终只剩一个值,但另一个写请求可能已经在缓存里写入了自己的版本值。
我在实际项目里用过一个改良版:不使用数据库的version字段,而是用Redis里存储的“逻辑时间戳”。更新数据时,生成一个全局递增的时间戳(用Redis的INCR命令实现),然后事务提交后把这个时间戳写入缓存(或者作为缓存key的一部分)。读取时对比时间戳,如果缓存时间戳小于数据库最新时间戳就回源。这样等于把版本号机制和Cache Aside合并了,但是代码复杂度确实上了一个台阶,适合那种数据更新频繁、读多写多的场景。
3.4 消息队列异步刷:最终一致性的正式落地方案
上面说的方案本质上都是“尽力同步”,做不到强一致,但如果业务可以接受短暂的不一致(比如几秒内),那么消息队列的异步刷方案是更工程化的选择。流程是:更新数据库成功后,向消息队列发送一条“缓存更新”的消息,由独立的消费者进程负责删除或刷新缓存。
这个方案的关键设计点有三个。第一,消息必须在数据库事务提交之后发送,不能在事务提交前发送,否则事务回滚了消息却发出去了。第二,消息要带数据标识和操作类型,消费者拿到消息后只负责缓存操作,不做业务逻辑判断,保持消费者足够简单。第三,消息队列要有重试机制和死信队列,因为缓存删除可能因为Redis故障一而再再而三地失败,重试了几次还不行就要把消息转入死信队列,人工介入处理。
PHP生态里常用的消息队列有RabbitMQ、Kafka和Redis Stream。对于中小项目,我推荐Redis Stream而不是RabbitMQ,因为Redis本来就部署着,Stream数据结构天然支持持久化和消费者组,不需要额外维护一套集群。下面是Redis Stream做异步刷的一个简化实现:
// 在更新数据库事务提交后,发送缓存删除消息 $redis = new \Redis(); $redis->connect('127.0.0.1', 6379); $redis->multi() ->xAdd('cache:refresh:stream', '*', [ 'table' => 'goods', 'id' => $goodsId, 'action' => 'delete_cache', ]) ->exec(); // 消费者(可以是一个常驻的Swoole进程,或定时脚本) // 使用 XREADGROUP 读取消息,处理Redis删除,失败则DELAY重试这个方案的优点是一致性窗口可控、操作异步化不会拖慢主接口、并且天然支持批量处理。缺点是需要额外的队列运维成本,消费者进程的监控、重启、幂等处理都要考虑。如果团队没有运维队列的经验,我建议先用延迟双删+分布式锁的组合,等业务规模上来再演进到消息队列方案。
3.5 Binlog订阅方案:把缓存刷新从业务代码中摘出去
如果追求“业务代码无侵入”,还有一条终极路线:订阅MySQL的binlog,解析出数据变更事件,再基于事件去刷新缓存。这个方案在国内的典型实现就是模拟Canal的工作方式,PHP端可以用开源组件如mysql-binlog-connector-php来监听binlog事件。
这个方案的思路是彻底改变“谁变更数据、谁负责刷新缓存”的模型。业务代码只需要写数据库,完全不用关心缓存。一个独立的监听进程会捕获到INSERT/UPDATE/DELETE操作,解析出变更的行数据,然后根据预定义的规则(表名、主键)去删除或更新对应的缓存。这样做的好处是:不管数据是PHP写的、脚本批量改的、还是运维手动在MySQL客户端改的,只要产生了binlog,缓存最终都会被同步更新,一致性逻辑和业务彻底解耦。
但这个方案对PHP来说有两道坎。第一,MySQL必须开启binlog,并且使用ROW格式(STATEMENT格式拿不到变更前后的具体值),这和很多公司默认的MIXED格式有冲突。第二,binlog监听进程必须保持长连接、维护位点(position),进程一旦挂掉要能恢复监听位置,否则会漏消息。我建议用这个方案之前先评估团队的运维能力,因为出了故障排查链路会比较长,不像在自己代码里加断点那么直观。目前我只有在一个数据量很大的报表系统上用过,效果确实好,但明显是“高阶玩家”的方案。
4. 实战中的组合策略与代码落地
4.1 按业务场景选择策略:没有银弹,只有取舍
讲了这么多方案,一定有人问:到底该用哪种?我的答案是按业务场景分三类处理。第一类,数据容忍短期不一致(比如文章浏览量、非核心的展示数据),直接用Cache Aside加缓存过期兜底就行,不需要花里胡哨。第二类,数据必须精确但并发不高(比如后台配置、商品上下架),用延迟双删加分布式锁,简单有效。第三类,数据必须精确且并发高(比如库存、余额、优惠券),必须上串行化或消息队列的强保证方案,并且要配合监控告警。
很多团队犯的错误是:一套方案打天下。订单服务和商品详情页的一致性要求完全不同,前者差一分钱就是P0事故,后者旧价格多展示几秒钟用户根本无感。正确做法是建立“一致性分级”概念,对不同数据定义不同的一致性SLA,然后匹配对应的技术方案,这样既不会因为方案不足出事故,也不会因为过度设计白白浪费性能。
4.2 PHP代码里的缓存更新模板:一套可直接抄的思路
为了不让这些方案停留在理论上,我整理了一套在Laravel里可以直接运行的模板,融合了分布式锁、延迟双删和版本号兜底。这套代码在我参与的几个项目里都验证过,遇到的大部分线上问题都能覆盖。
<?php namespace App\Services; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; class ConsistentCacheService { /** * 更新业务数据,并保证缓存最终一致 * * @param int $goodsId * @param array $data * @return bool */ public function updateGoods(int $goodsId, array $data): bool { // 1. 获取分布式锁,防止并发写导致乱序 $lockKey = 'lock:goods:' . $goodsId; $lockValue = Str::uuid()->toString(); $locked = Cache::lock($lockKey, 5)->get(function () use ($goodsId, $data) { // 2. 事务更新数据库 DB::transaction(function () use ($goodsId, $data) { DB::table('goods')->where('id', $goodsId)->update($data); }); // 3. 第一波删缓存(立即删) Cache::forget('goods:detail:' . $goodsId); Cache::forget('goods:price:' . $goodsId); // 4. 推送延迟删除任务(1秒后再删一次),兜底并发读回填 dispatch(new \App\Jobs\DelayDeleteCache('goods:detail:' . $goodsId, 1)); dispatch(new \App\Jobs\DelayDeleteCache('goods:price:' . $goodsId, 1)); }); return $locked; } }这个模板有几个细节值得重点说明。第一,Cache::lock是Laravel自带的原子锁,底层走Redis的SET NX EX,避免了自定义锁的很多坑,我强烈建议别自己造轮子。第二,我把sleep改成了延迟任务(DelayDeleteCache),在异步任务里实现第二次删除,这样主流程不会被阻塞,接口延迟不会增加。第三,锁的超时时间设成5秒,如果业务在锁内执行超过5秒,锁会自动释放,其他请求就能进入,这就牺牲了一点一致性来保证系统不会死锁。
DelayDeleteCache这个Job的实现也很简单,核心就是消费时再删一次缓存:
public function handle(): void { // 延迟指定的秒数后执行删除 if ($this->delay) { sleep($this->delay); } Cache::forget($this->cacheKey); }有朋友会问:如果Job还没执行,服务就重启了怎么办?那缓存里的脏数据就要等到它过期才能清理。所以这套方案里我始终强调:所有缓存都必须设置过期时间,这个过期时间就是你的最终兜底。即使前面所有同步手段都失败了,最坏情况不过是缓存多存活到过期时间,到点后自动失效,读请求就会回源数据库,数据自然恢复一致。
4.3 缓存穿透、击穿、雪崩的应对:与一致性方案搭配使用
前面提到过,只删缓存不更新缓存会带来额外的数据库压力,这种压力在极端情况下会演变成缓存穿透(大量请求查一个不存在的key)、缓存击穿(key过期瞬间大量请求打到数据库)和缓存雪崩(大量key同时过期)。这三个问题虽然不直接等同于一致性问题,但它们往往在同一套系统里并发出现,处理不好会让整个方案崩溃。
穿透的解法是缓存“空值”。查数据库结果为null的key,也要在缓存里放一个占位符(比如存一个空字符串或特殊标记),并设置较短的过期时间(例如60秒),这样同一个key的重复查询就不会反复打到数据库。另一个解法是使用布隆过滤器,把所有可能存在的key预先加载进过滤器,查询前先判断key是否可能存在,不存在就直接返回null,但布隆过滤器对删除操作支持不友好,工程复杂度高,中小项目不太建议。
击穿的解法是互斥锁。当缓存未命中时,先尝试获取一个针对该key的锁,拿到锁的请求才去查库并回填缓存,其他请求先阻塞等待或直接返回过期数据。在PHP里可以用Redis的SET NX实现这个互斥锁,但要注意锁的过期时间不能太短,否则查库还没结束锁就释放了,其他请求会同时涌入。雪崩的解法是“过期时间加随机值”。批量设置缓存时,每个key的过期时间在基础值之上加一个随机偏移(比如60秒到120秒之间随一个值),避免大量key在同一秒过期。
这里重点提醒一句:这套“防穿透、防击穿、防雪崩”的方案应该和一致性方案一起规划,不要先把缓存加进去然后出了事故再补。我见过太多项目是缓存架构搭好了,发现数据对不上,开始补一致性逻辑;补完一致性,又发现数据库被穿透打挂了,再回来加互斥锁,搞得代码里全是补丁。好的做法是设计阶段就把这些应对策略想清楚,一次到位。
5. 线上问题排查与快速止血
5.1 手动模拟数据不一致的复现方法
排查线上不一致问题,第一步往往是复现。但生产环境的并发条件很难完全模拟,我常用的办法是分两层。第一层,在测试环境用JMeter或自写脚本模拟并发读写。一个线程循环更新数据,另外几个线程循环读取缓存,跑个几分钟,把缓存的值和数据库的值做对比,看能不能复现不一致。如果能复现,基本上问题就能稳定定位。第二层,如果测试环境复现不了(因为并发量不够),就用线上日志分析。关键是把每一次缓存写入动作和每一次数据库更新动作都打印成结构化日志,包含key、数据值、时间戳、进程标识,然后做时序对齐,看看有没有数据库更新和缓存写入错位的情况。
这里有个实操技巧:排查缓存不一致时,不要盯着Redis的当前值看,而要看“时间线”。Redis的MONITOR命令可以实时打印所有读写命令,配合业务日志的时间戳,能精确还原每个操作的先后顺序。但MONITOR命令本身有性能开销,线上高峰期不要开,建议在流量低峰或者用Slave节点来执行。我在几次关键排查中都是靠MONITOR锁定了问题的:明明代码里逻辑没问题,但实际执行顺序就是有交叉,一看日志才发现是某个线程的重试机制在捣乱。
5.2 紧急修复:先恢复服务,再追求“根治”
线上出了不一致问题,第一目标不是定位根源,而是止血。当价格、库存这类关键数据错乱时,最直接的止血方式是“直接清洗缓存”。用脚本批量删除所有相关的缓存key,让流量回源数据库重建缓存。如果缓存key太多无法全量删除,可以只清理冲突的那几条,让它们先恢复,业务侧虽然会慢一点,但数据至少是对的。
但这里有个容易被忽视的操作顺序:先暂停异常写入入口(比如通过配置中心关闭定时任务、限流降级),再清洗缓存。很多团队一看到数据不一致就把缓存清了,结果后台的定时任务还在疯狂写入,缓存刚删完又被写入脏数据,问题就“怎么都修不好”了。正确的做法是优先级排序:暂停写入 -> 清理缓存 -> 观察是否恢复 -> 再逐步放开写入。
止血之后不要急着上线修复代码。先把问题发生的时间窗口、涉及的key范围、当时的日志片段收集齐,确认根因之后再改代码。我踩过最大的坑就是:线上问题一出现,程序员的第一个反应是“立刻改代码上线”,结果根因没定位准,改了个表面的东西,上线后问题转移到了另一个场景,反而更难查。宁可多花半小时把时序日志理清楚,也不要靠猜去改代码。
5.3 监控体系:给一致性加一道安全网
最后一条建议:给缓存一致性建立监控。这个监控分两层。第一层是数据层,写一个定期脚本,扫描关键业务表的某几条数据,对比Redis缓存里的值,不一致就触发告警。第二层是日志层,在缓存删除失败、锁获取超时、队列消息积压这些关键事件上打点,接入到监控平台,设置阈值告警。
我在项目中经历过的一次经典案例:某天凌晨,定时补货脚本批量更新商品库存,用了一个不太规范的方法——直接用SQL语句UPDATE,绕过了业务代码的缓存删除逻辑。第二天早上发现线上价格大面积错误,就是因为这个脚本只写了库,没删缓存。后来我就在监控脚本里增加了“数据库变更审计”:监听binlog事件,对比缓存值,发现不一致立即发告警。从那之后,团队再也没出现过类似“漏删缓存”的问题,因为无论谁来改数据库,监控都会把不一致暴露出来。
这个“安全网”思路也建议读者务必落地:它不解决根因,但能把“偶尔发生的不一致”变成“可感知的事件”,让你能尽早发现、尽快介入。否则,不一致就像房间里的大象,你整天提心吊胆,直到它突然爆出来,可能已经是不可挽回的线上事故了。
6. 写给遇到缓存一致性问题的你
这个话题写到这里,该说的原理和方案基本都覆盖了。最后说一点我的真实体会:与其纠结“哪个方案最完美”,不如先想清楚“你的业务能容忍多久的不一致”。把一致性要求分级,对不同级别的数据采用不同强度的保证,这是成本最低、效果最好的策略。我见过不少团队为了追求“绝对一致”,代码里加了无数锁和队列,结果接口耗时从50ms涨到500ms,用户体感变差了,业务也没见得比原来可靠多少。
另外建议在自己的项目里,把缓存操作单独封装一个类或Service,不要散落在各处业务代码里。这样后续不管是加延迟删除、加锁还是换消息队列,都只是改动一个类的事情,而不是满仓库找Redis调用点。这个习惯在遇到问题时能帮你省下一个通宵的排查时间。缓存一致性没有银弹,但有迹可循,希望这篇内容能帮你把“迹”抓住。