双写一致性这个问题,凡是做PHP后端超过两三年的,几乎都会撞上。尤其是项目里同时上了Redis缓存和Elasticsearch搜索之后,"明明写了数据库,缓存里却还是旧数据"这种破事,绝对让人头大。我最早在某电商项目里负责商品中心的时候,就是被线上用户投诉"改完价格不生效"给逼着去研究了这块。今天把实战中用过的几个最终一致方案完整拆一遍,包括延迟双删、本地消息表、Binlog订阅,直接给你能落地参考的PHP实现思路。
1. 双写一致性问题的真实场景
1.1 一次线上事故让我开始重视这个问题
当时的情况很典型。商品后台支持运营修改价格,前端商品页为了扛住大促流量,最早是直接读MySQL,后来压测发现扛不住,就引入了Redis做商品详情缓存。改造上线没几天,运营反馈"价格改了之后,前端页面偶尔还是旧价格,刷新好几次才正常"。一开始以为是缓存过期时间太长,把过期时间从2小时缩到10分钟,问题依然存在。后来查日志才发现,不是过期时间的问题,是"更新数据库成功,但更新缓存失败"或者"更新缓存的顺序不对"导致的胜出。
那次排查花了两天,最后定位到几个典型场景,其中一个特别坑:两个请求并发来改同一个商品,请求A先把新价格写进缓存,请求B再把旧价格写进缓存,结果旧值把新值覆盖了。这个场景不通过加锁或者版本号,根本防不住。从那之后我就认了一件事:双写一致性,必须用一套系统性的最终一致方案来兜底,靠"改改缓存过期时间"这种土办法,迟早还要出大事。
1.2 双写到底写在哪些"两张表"里
很多人听到"双写"第一反应就是"同时写数据库和缓存",其实在实际的PHP项目里,双写至少有三种常见形态:
第一种是MySQL加Redis。这是最常见的读多写少场景,数据库做主存储,Redis扛读流量。写入路径是"更新MySQL,同步更新或删除Redis缓存"。
第二种是MySQL加Elasticsearch。电商后台的搜索、筛选、聚合统计,MySQL的LIKE和联合索引扛不住,于是引入ES做商品检索。写入路径变成了"更新MySQL,同时同步一份数据到ES",否则搜索到的还是旧商品数据。
第三种是主库加从库,或者主库加其他异构存储。比如订单数据既要进MySQL做事务处理,又要同步到数仓做离线分析,或者同步到OLAP引擎做实时报表。
不管是哪一种形态,本质问题都一样:两个存储系统之间没有原子事务保证,做不到"要么同时成功,要么同时失败",必然存在一个时间窗口,让两边数据不一致。PHP作为写业务逻辑的语言,就处在这个不一致窗口的正中央。
1.3 PHP业务里最常见的四种双写不一致
我整理了一下实际项目中反复出现的四类不一致,几乎覆盖了绝大多数线上问题。
第一类是写库失败或写缓存失败的"半成功"问题。比如先更新MySQL成功,后删除Redis缓存时Redis超时了,缓存里还是旧数据;反过来先删了缓存,再更新MySQL时SQL报错回滚了,缓存被删除后用户请求把旧数据库数据重新回填。
第二类是并发读写导致的"缓存回填旧数据"问题。线程A读缓存未命中,去MySQL查到旧数据准备回填;此时线程B完成数据库更新并删除了缓存;线程A紧接着把旧数据写回了缓存。经典的Cache Aside并发漏洞。
第三类是并发写导致的"后写先到"覆盖问题。两个写请求同时到达,请求1先更新MySQL为新值,请求2后更新MySQL为另一个新值,但缓存更新的顺序恰好相反,请求2先写缓存,请求1后写缓存,最终缓存里是旧值。
第四类是"删除缓存时正好有大量读请求"导致的雪崩问题。缓存刚删除还没来得及回填,一瞬间的流量全部打到了MySQL上,可能直接把数据库打挂。
注意:这些问题不是PHP特有的,Java、Go的项目同样存在。但PHP项目因为部署形态(PHP-FPM进程模型)、开发者水平参差、团队很少专门设计中间件层,踩中的概率反而更高,尤其集中在"只改了业务代码但没设计同步策略"的中小型项目上。
2. 最终一致方案的设计底层逻辑
2.1 为什么不用强一致
有同学可能会问:"既然双写会不一致,为什么不搞强一致?比如分布式事务里经典的二阶段提交,或者让缓存和数据库在同一事务里更新。"
理论上可以,但工程上几乎不这么干。原因有三个。第一,性能损耗太严重。二阶段提交需要多个节点之间协调锁定资源,一次写操作要好几轮网络交互,对电商这种高并发写场景是灾难。第二,PHP侧很难实现。PHP是短生命周期进程模型,一次请求结束后所有资源都释放了,要实现一个全局事务协调器,要么基于外部服务,要么引入巨重的中间件,普通项目根本折腾不起。第三,很多双写场景根本不需要强一致。商品价格改完,用户10秒内刷新看到新价格,完全能满足业务需要,没必要为了"绝对一致"付出那么高的代价。
所以在实际工程里,双写问题的主流解法是"最终一致性":允许系统在一个短暂的时间窗口内不一致,但保证在没有新写入的情况下,经过一段时间后数据会自动趋同。简单说就是"我不跟你保证立刻一致,但我保证过一会儿一定一致,而且不一致的窗口你业务能接受"。
2.2 最终一致的三板斧:异步、重试、幂等
最终一致方案看着五花八门,底层逻辑其实就三板斧。
第一板斧是异步化解耦。不要让主业务线程去同步等待所有的双写操作完成。更新完MySQL之后,把"需要同步缓存/ES"这件事扔到消息队列或者本地任务表里,让另一个进程慢慢消费。主线程快速返回,响应时间不受影响,同时也天然形成了一个重试缓冲地带——消息在队列里,失败了可以反复拉起来重试。
第二板斧是可靠重试。双写失败是常态,关键是要有重试机制。常见的做法是本地消息表加定时任务扫描,或者消息队列自带的失败重投机制。重试的设计要点是"直到成功为止",不能重试几次就放弃了,否则数据永远补不上。
第三板斧是幂等保护。重试的次数多了,消费者可能重复处理同一条消息。比如商品数据同步任务被重复执行,如果消费者是无脑更新,那还好,但如果这个同步操作不是天然幂等的,就可能产生脏数据。所以消费者端必须要做幂等,用业务唯一标识去重。
2.3 先看看方案全景图
基于这三板斧,我把目前主流的PHP可用方案拉了一张表,后面逐一生动详细地拆。
| 方案 | 核心思路 | 实现成本 | 一致性窗口 | 适用场景 |
|---|---|---|---|---|
| Cache Aside + 延迟双删 | 更新DB后删除缓存,延迟一段时间再删一次 | 低 | 秒级到分钟级 | 简单缓存场景,无MQ基础设施 |
| 本地消息表 + MQ | 业务事务里写消息表,定时任务投递到MQ,消费者同步 | 中 | 秒级到分钟级,取决于扫描频率 | 需要高可靠同步,且已有MQ |
| Binlog订阅 | 解析MySQL binlog,同步到缓存/ES/其他存储 | 高 | 秒级 | 要求业务代码零侵入 |
| 事务消息 | 利用MQ事务消息特性,半消息确认后提交 | 中高 | 秒级 | 团队熟悉特定MQ且有事务消息能力 |
选方案有个关键判断:你的团队有没有现成的消息队列。有MQ,优先走本地消息表加MQ;没MQ,就用延迟双删加补偿;想彻底和业务代码解耦,再考虑Binlog。下面逐个展开。
3. 方案一:Cache Aside 加延迟双删(PHP最快落地)
3.1 Cache Aside 的漏洞在哪里
Cache Aside 是大家最早接触的缓存模式。读的时候先读缓存,没命中就读数据库,然后回填缓存;写的时候先更新数据库,然后删除缓存。这个模式本身逻辑没错,但存在一个经典的并发漏洞:
线程A读缓存未命中,去数据库查到了旧值;此时线程B更新了数据库,并删除了缓存;线程A拿到查询结果后,把旧值回填到了缓存。这个回填操作发生在"删除缓存"之后,于是缓存里又被塞进了旧数据。更可怕的是这个旧数据可能要在缓存里待很久,直到下一次过期或被删除。
要堵住这个漏洞,可以在更新数据库后删缓存的基础上,再延迟一段时间,第二次删除缓存。因为线程A回填旧数据往往发生在删除操作后的几十到几百毫秒内,延迟删除可以把"被回填的旧值"再次清掉。这就是"延迟双删"名字的由来。
3.2 PHP里实现延迟双删的几种姿势
PHP里做延迟删除,有一个天然的坑:PHP-FPM下每个请求是同步阻塞的,你不能在请求里sleep(1)等一秒再去删缓存,那样会让接口响应时间变长,还占用FPM进程。所以延迟双删必须"异步延迟"。
我实际用过的有三种姿势。
第一种是用Redis的ZSet做简单的延时队列。删除缓存这个操作本身不需要复杂格式,把缓存key作为成员,把"当前时间戳加延迟毫秒数"作为分数,丢进一个固定的ZSet。然后有一个定时任务(比如Crontab每500毫秒跑一次)扫描分数小于当前时间的成员,执行真正的删除命令并移除成员。代码如下:
<?php // 写入侧:业务更新DB后,把缓存key丢进延时队列 public function cacheDoubleDeleteAfterDbUpdate(string $cacheKey, int $delayMs = 500): void { $score = (int)(microtime(true) * 1000) + $delayMs; $redis->zAdd('delay_delete_queue', $score, $cacheKey); } // 定时任务侧:每500ms执行一次 public function processDelayDelete(): void { $now = (int)(microtime(true) * 1000); $keys = $redis->zRangeByScore('delay_delete_queue', 0, $now, ['limit' => [0, 200]]); foreach ($keys as $key) { $redis->del($key); $redis->zRem('delay_delete_queue', $key); } }第二种是用RabbitMQ的延迟队列插件。RabbitMQ 3.8以上可以通过x-delayed-message类型声明一个延迟交换机,投递消息时设定延迟时间,消费者到点才收到消息。PHP端用amqp扩展发送一条延迟消息,消费者收到后执行redis删除就行。这个方式的好处是不用自己维护延时队列数据结构,坏处是引入MQ。
第三种是在业务代码里注册register_shutdown_function或者用异步任务系统(比如Swoole的task/AsyncTask)来延迟执行删除。适合对响应时间极其敏感且已经用了Swoole常驻内存模式的项目。
3.3 延迟时间到底怎么定
延迟双删最关键的参数就是延迟时间。时间设短了,并发读线程还没完成"查库回填"这个动作,第二次删除就执行了,旧值还是会被回填;时间设长了,不一致窗口变大,用户体验变差。
我自己的经验公式是:延迟时间 = 业务平均读耗时 + 网络抖动余量。比如缓存未命中时,一次MySQL查询加一次Redis写回,平均耗时在200毫秒左右,那延迟时间可以设在500毫秒到1秒之间。千万别拍脑袋设个10秒,用户改完价格十秒内看到的还是旧价格,运营又要来投诉。
注意:延迟双删不是银弹。如果业务里存在"大批量读线程同一秒内集中穿透到MySQL"的场景,回填动作可能持续好几秒,固定延迟1秒根本挡不住。遇到这种场景,要么把延迟时间加大到几十秒,要么直接换方案二(消息队列异步同步),后者的一致性窗口更可控。
3.4 删除失败的兜底补偿
延迟双删有个隐患:第二次删除缓存也可能失败。比如Redis实例重启、网络分区,删除命令一直执行不成功。如果删除操作没有兜底,旧数据就会一直赖在缓存里。
我的做法是给延迟删除的每个key增加一个"删除次数"和"删除时间"记录。用另一个Hash结构存放待删除的key以及尝试次数。定时任务扫描时,如果删除失败,尝试次数加一,下一次继续扫描;超过最大次数(比如5次),发给告警,同时把key写入一个专门的"补偿删除队列",由人工或者补偿脚本稍后处理。
<?php public function retryDelete(): void { $items = $redis->hGetAll('cache_delete_retry'); foreach ($items as $key => $retryCountJson) { $retryCount = json_decode($retryCountJson, true); try { $redis->del($key); $redis->hDel('cache_delete_retry', $key); } catch (\Exception $e) { $retryCount++; if ($retryCount >= 5) { $this->alert(sprintf('cache delete failed after 5 retries: %s', $key)); } $redis->hSet('cache_delete_retry', $key, json_encode($retryCount)); } } }这样即使删除操作失败,也能在几分钟内通过重试补偿,最终把缓存清掉,恢复一致性。
4. 方案二:本地消息表加消息队列异步同步(推荐方案)
4.1 一个事务搞定"业务和消息"
如果项目里已经用了RabbitMQ或者Kafka,我强烈推荐这个方案。它的核心思想非常巧妙:在同一个数据库事务里,既更新业务数据,又插入一条"待同步消息"到本地消息表。因为这两个操作共享同一个事务,所以要么一起成功,要么一起失败。这就解决了"MySQL更新成功但消息没发出去"的问题。
接着由一个定时任务扫描消息表,把状态为"待发送"的消息投递到MQ,投递成功再将消息标记为"已发送"。消费者从MQ拿到消息后,去更新Redis缓存或者ES索引,完成同步。整个链路里,MySQL的业务数据、消息表、MQ、消费者四者之间通过"本地事务 + 异步重试"最终达成一致。
4.2 生产者端完整代码实拍
先建消息表,我贴一份实际用过的表结构:
CREATE TABLE `message_queue` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `topic` varchar(64) NOT NULL DEFAULT '' COMMENT '消息主题,比如 product.update', `business_id` varchar(64) NOT NULL DEFAULT '' COMMENT '业务主键ID,用于幂等', `payload` text NOT NULL COMMENT '消息体,JSON字符串', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0待发送 1已发送 2已消费 3发送失败', `retry_count` tinyint(4) NOT NULL DEFAULT '0' COMMENT '重试次数', `create_time` int(11) NOT NULL DEFAULT '0', `update_time` int(11) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), KEY `idx_status` (`status`, `retry_count`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;生产者这边的代码,核心就是"在一个事务里完成业务更新和消息插入":
<?php public function updateProduct(int $productId, array $data): void { $pdo = $this->getPdo(); $pdo->beginTransaction(); try { // 1. 更新商品主表 $pdo->update('product', $data, ['id' => $productId]); // 2. 写入本地消息表,同一事务 $pdo->insert('message_queue', [ 'topic' => 'product.update', 'business_id' => (string)$productId, 'payload' => json_encode(['product_id' => $productId, 'updated_at' => time()], JSON_UNESCAPED_UNICODE), 'status' => 0, 'create_time' => time(), 'update_time' => time(), ]); $pdo->commit(); } catch (\Throwable $e) { $pdo->rollBack(); // 记录日志,业务更新失败,消息也不会插入,不会产生半一致 throw $e; } }这里的细节在于:消息表插入和业务更新在同一个事务里,任何一步失败都会回滚。你完全不用担心"数据库更新了但消息没记录"这种低级错误。
定时扫描投递的脚本,我一般用Crontab每3秒执行一次:
<?php // 定时任务:每分钟执行N次,这里以每3秒为例可以用crontab * * * * * 搭配sleep控制 public function scanAndPublish(): void { $messages = $this->getPdo()->select( "SELECT * FROM message_queue WHERE status = 0 AND retry_count < 10 ORDER BY id ASC LIMIT 500" ); foreach ($messages as $message) { try { $this->mq->publish($message['topic'], $message['payload']); $this->getPdo()->update('message_queue', ['status' => 1, 'update_time' => time()], ['id' => $message['id']] ); } catch (\Throwable $e) { // 投递失败,重试次数加一 $this->getPdo()->update('message_queue', ['retry_count' => $message['retry_count'] + 1, 'update_time' => time()], ['id' => $message['id']] ); // 记录异常日志 } } }4.3 消费者端的幂等设计
消费者从MQ拿到消息后更新Redis或者ES。这里最大的坑是消息可能被重复投递。我遇到过的情况:RabbitMQ的消费者处理消息时进程崩溃,没有发送ack,MQ重新投递;或者投递成功但是ack丢失,消息又被投了一遍。所以消费者必须做幂等。
幂等的做法很多,最简单的用Redis的SETNX做一个消费标记:
<?php public function onMessage(array $message): void { $msgId = $message['msg_id'] ?? ''; if (empty($msgId)) { $msgId = md5($message['business_id'] . $message['topic']); } $key = 'mq:consumed:' . $msgId; // 尝试设置,如果之前没处理过则返回true $locked = $this->redis->set($key, 1, ['NX', 'EX' => 3600]); if (!$locked) { // 已处理过,直接ack $this->ack($message); return; } // 执行真正的业务同步 $productId = $message['payload']['product_id']; $product = $this->getProductFromDb($productId); if ($product) { $this->redis->setex('product:' . $productId, 3600, json_encode($product)); $this->es->updateIndex('product', $productId, $product); } else { // 商品已经被删除,删除缓存和ES数据 $this->redis->del('product:' . $productId); $this->es->deleteIndex('product', $productId); } $this->ack($message); }注意这类幂等标记要有过期时间。如果设成永久有效,万一业务逻辑升级需要重新处理同一条业务ID的消息,会被幂等挡住。设个1小时或者24小时足够覆盖MQ重试窗口,之后就算重复投递,业务上一般也能接受(因为此时缓存大概率已经被最新的数据覆盖了)。
4.4 定时任务扫描补偿
本地消息表有一个必须面对的难题:消息表会越来越大。如果每3秒扫描一次status = 0的待发送消息,数据量小的时候没感觉,但线上跑几个月后表可能有几十万条历史消息。如果每次都按status = 0扫描,可能会扫到很多历史数据,或者因为索引设计不好导致扫描变慢。
我的经验是"扫描任务跟上清理任务一起做"。扫描时按id排序加LIMIT 500,只处理前500条待发送消息;同时每天凌晨跑一个清理脚本,把status = 2(已消费)且创建时间超过3天的消息物理删除。如果某条消息重试次数超过10次还是失败,就把它标记为status = 3发送失败,不再自动扫描,同时发告警让人工介入。这样消息表永远保持在一个可控的量级,定时任务也始终聚焦在"待处理"的数据上。
实操心得:本地消息表方案看着多了一张表一个定时任务,好像比延迟双删麻烦,但它带来的好处非常实际——业务更新和消息记录是原子性的,消息投递有重试机制,消费者有幂等保护,整个链路每一环都有据可查。一旦线上出问题,查消息表的状态流转比翻业务日志快得多。
5. 方案三:订阅MySQL Binlog做增量同步(低侵入)
5.1 原理:把MySQL当数据总线
方案二虽然可靠,但有一个"侵入性"问题:你必须修改业务写代码,在每次更新数据时都要记得插入消息表。如果项目里的写入点非常多,或者团队里有新同学经常漏写,消息表就会缺数据。那有没有一种方案能做到"业务代码完全不用改"?
有,就是订阅MySQL的Binlog。原理非常简单:MySQL主从同步本身就是靠从库解析主库的Binlog日志来复制数据。我们完全可以"伪造"一个从库,解析Binlog里每一行数据的INSERT、UPDATE、DELETE操作,把变更推到MQ或直接同步到Redis/ES。这样PHP业务代码完全无感,该怎么写怎么写,数据库层的变更被Binlog忠实记录下来,同步逻辑在更底层完成。
要做到这一点,Binlog格式必须设置成ROW模式。因为只有ROW模式才会记录每一行数据变更前后的完整值,STATEMENT模式只记录SQL语句,你根本不知道具体改了哪些字段、哪些值。
5.2 整体架构和组件选型
典型的架构是:MySQL -> Canal(解析Binlog)-> MQ -> 同步消费者。Canal是阿里巴巴开源的MySQL Binlog解析中间件,部署后模拟MySQL从库,把Binlog解析成结构化数据。它可以将数据直接输出给MQ,也可以由客户端主动拉取。我这边的项目里选择的是"解析后投递到RabbitMQ",因为消费者这边复用已有的消息处理逻辑,不额外费事。
部署上要注意几个点。第一,MySQL需要开启Binlog并设置binlog_format=ROW;第二,需要给Canal分配一个有SELECT和REPLICATION SLAVE, REPLICATION CLIENT权限的数据库账号;第三,Canal自身记录了同步位点(position),崩溃重启后可以从上次的位置继续消费,不会丢数据。
同步消费者拿到Binlog解析后的数据,判断操作类型:
- 如果是INSERT或UPDATE,把最新数据更新进Redis和ES;
- 如果是DELETE,删除Redis缓存和ES索引。
5.3 PHP如何配合Binlog方案
有同学会问:"既然Binlog方案都说业务代码零改动了,是不是PHP这边什么也不用做?"也不完全是。至少有两件事需要PHP侧配合。
第一件事是"回填缓存时的数据格式"。Binlog解析出来的是变更后的行数据,但没有业务上经过加工后的字段(比如商品详情页展示用的格式化描述、价格计算后的促销价)。所以消费者拿到Binlog后,通常不会直接用原始行数据回填缓存,而是把变更事件的ID发到MQ,PHP消费者再从主库查询最新完整数据,做业务加工后写入缓存。说白了,Binlog在这里更像是"变更触发器",替代了方案二里的本地消息表。
第二件事是全量补偿。Binlog只记录增量变更,如果Redis缓存因为某些原因被清空或者冷数据需要预热,得有一个全量同步任务把MySQL数据一次性刷进Redis/ES,全量加增量才能保证最终一致。全量同步一般在凌晨低峰期跑,只扫主表,分批写入缓存。
方案三的最大好处是同步逻辑和业务代码解耦。坏处是引入了一套额外的中间件(Canal),运维成本上升。如果团队总共就两三个人,没有专职DBA或者运维,我不太建议一上来就上这个方案,可以先从方案二做起,等量大到业务代码改不动时再演进。
6. 方案四:事务消息方案
6.1 什么是事务消息
本地消息表方案解决了"本地事务和消息投递不一致"的问题,但代价是要自己维护消息表和定时任务。有些MQ直接提供了"事务消息"能力,把这个问题下沉到MQ内部,业务代码可以少写一些。
事务消息的执行流程一般是:
- 业务方调用MQ发送一条"半消息"(半消息对消费者不可见);
- MQ返回发送成功,业务方执行本地事务(更新MySQL);
- 本地事务成功,业务方调用MQ提交半消息,MQ把消息变成对消费者可见;
- 本地事务失败,业务方调用MQ回滚半消息,消息被丢弃;
- 如果业务方在步骤3之后没有提交或回滚,MQ会回调业务方提供的接口,由业务方根据本地事务的结果决定提交还是回滚。
这个流程看着复杂,但它把"事务消息的最终状态确认"交给了MQ和业务方的回调接口,不需要本地消息表。
6.2 PHP中使用事务消息的限制
PHP要使用事务消息,得看你的MQ选型。RabbitMQ经典版本没有原生的"事务消息"特性,通常需要配合延迟队列和本地消息表来做补偿;RocketMQ则原生支持事务消息,但RocketMQ的PHP客户端生态一般,用起来没有Java生态顺滑;Kafka在2.6版本后引入了事务API,不过PHP端的客户端支持也比较有限。
所以我的建议是:如果团队已经在用RocketMQ或者有专门的中间件团队,可以考虑事务消息;如果用的是RabbitMQ,就别硬搞事务消息了,直接走方案二。事务消息的本质还是"半消息确认 + 本地事务 + 补偿回调",跟本地消息表殊途同归,选哪个取决于你手里的MQ工具。
7. 线上问题排查与避坑实录
7.1 缓存删除之后还是旧数据
这个现象我排查过很多次,也是最容易误导人的。按照延迟双删的思路,删了两次缓存,为什么还是旧数据?
先说一个最常见的原因:删除缓存前,有请求已经在数据库里查到了旧数据,并且这个请求的网络耗时特别长(比如慢查询、跨机房网络抖动),导致"回填旧值"的动作发生在第二次删除之后。碰到这种情况,可以把延迟时间适当加大,同时检查慢查询,把读链路耗时压下来。
第二个原因是缓存key写错了。比如商品详情页缓存key是product:detail:{id}:{platform},但删除的时候只删了product:{id}。这种问题代码审查时很难发现,最好在框架层封装统一的缓存工具类,让所有业务读写走同一个方法,避免key格式不一致。
第三个原因是"双删删除的缓存和数据回填的缓存不是同一个Redis库"。PHP项目里如果多个业务共用一个Redis实例,有人用了db0,有人用了db1,删除时没有指定库,就会出现"看起来删了但读的还是旧数据"的错觉。
排查建议:遇到"缓存删除后还是旧值",第一步不是调延迟时间,而是先确认删除的key和读取的key是不是完全一致,包括Redis库、前缀、序列化格式。用
redis-cli --scan --pattern把相关key拉出来看一眼,比任何代码分析都快。
7.2 消息队列积压怎么处理
本地消息表加MQ方案最怕的就是消息积压。商品批量更新的时候,一次可能产生几十万条product.update消息,消费者处理不过来,客户端就会延迟几十分钟才看到最新数据。
常规解法是"水平扩容消费者"。RabbitMQ里同一个队列可以有多个消费者并发消费,注意控制每个消费者的prefetch_count,别让某个消费者一次拉取太多消息导致内存暴涨。PHP消费进程如果用的是php bin/consumer.php这种长驻脚本,可以用supervisor管理多个进程实例。
还有一招是"隔离队列"。把同步时效要求高的消息(比如价格修改)放进一个独立的MQ队列,单独配置消费者数量;同步时效要求低的消息(比如访问量统计)放另一个队列。避免高优消息被低优消息堵住。
如果真的积压到十几万条,短时间消费不完,可以考虑"降级直接回源":在缓存层做一个开关,发现缓存中数据时间戳太旧时直接查MySQL,保证用户读到相对新的数据,等MQ消费追上后再把缓存更新回来。
7.3 全量初始化时的坑
不管是方案一还是方案二,首次上线时都会面临一个"历史数据如何同步"的问题:线上已经有一堆旧数据了,缓存和ES都没有。全量初始化的坑主要在数据一致性上。
我踩过最狠一个坑:全量脚本把MySQL数据扫出来写入Redis的时候,业务方正好在改数据,导致全量同步完成后,缓存里反而不是最新值。解决方式分两步:先做全量同步,再立即做一次增量补偿。具体操作是全量同步完成后,记录当前MySQL的Binlog位点,从该位点开始重新走增量同步,把同步期间产生的变更再补一遍。没有Binlog位点能力的话,可以采用"全量任务开始前记录最大主键ID,结束后扫描所有大于该ID的数据做逐条同步"的替代方案。
7.4 线上故障应急清单
最后整理一份应急排查清单,遇到双写不一致问题时按顺序排查,能省不少时间:
| 排查项 | 可能的根因 | 快速验证方法 |
|---|---|---|
| 缓存读到的数据是不是旧值 | 缓存删除失败、key不一致、回填覆盖 | 对比Redis值和MySQL值,检查删除日志 |
| 消息表有没有积压 | 定时任务挂了、MQ连接异常 | 查看消息表status=0的记录数,是否持续增长 |
| MQ消费者有没有在跑 | 消费者进程崩溃、被Supervisor反复重启 | 查看RabbitMQ/Kafka的消费者连接数和堆积量 |
| 全量增量有没有交叉覆盖 | 全量同步和增量同步并发执行 | 观察同步任务执行时间和最新数据更新时间 |
| 同步代码有没有报错 | ES字段冲突、Redis序列化问题 | 查看消费者日志中的异常堆栈 |
8. 我的选型建议
这几个方案我都实际落地过,说点直接的选型判断。
如果你的项目只有MySQL加Redis,没有MQ基础设施,团队就三五个人,首选"Cache Aside加延迟双删"。把延迟删除的队列和重试补偿做好,能解决90%的缓存不一致问题。说句实在话,大部分中小项目的缓存不一致场景,靠延迟双删完全够用,没必要为了追求"优雅"把架构搞得特别复杂。
如果项目里已经有RabbitMQ或者Kafka,且业务对一致性要求比较高,比如商品、库存、订单这类数据,直接上"本地消息表加MQ异步同步"。这套方案可靠、可控、可排查,消息表就是天然的审计日志,出了问题能顺着表里的状态流转去定位。我们线上商品中心就是用的这个方案,运行一年多没出过大岔子。
如果你所在团队有专门的运维或者中间件团队,而且业务写入点特别多、已经不想再在每个写方法里插入消息表,那可以升级到"Binlog订阅"方案,让同步逻辑彻底从业务代码里剥离开。但要注意,这不是一个"省事"的方案,它只是把"侵入性"从业务代码转移到了基础设施上,你需要花精力去维护Canal集群、监控同步延迟。
我个人在实际操作中的体会是:方案没有绝对的好坏,只有合不合适。双写一致性问题的本质是"多个存储之间的数据同步",解决思路永远是"异步化、可靠重试、幂等保护"这三板斧。你只要把这三板斧融进自己的方案里,无论选哪一种形态,都能做出一个稳健的最终一致系统。真要说有什么值得反复提醒的,那就是不要漏掉补偿和监控——一致性方案不怕失败,就怕失败了没人知道。