☰
PHP后端双写一致性解决:缓存与最终一致方案全解析
2026/10/13 4:09:54 网站建设 项目流程

做PHP后端的朋友,迟早会碰到双写一致性这个坑。所谓双写,最常见的就是同一份业务数据要同时落到MySQL和Redis缓存,或者MySQL和Elasticsearch索引。刚接手项目的时候一切都很简单,一张表读写都直接走数据库,什么问题都没有;等流量上来了,你把热数据塞进Redis,把搜索逻辑迁到ES,麻烦就跟着来了:数据库更新成功了,缓存没删干净,用户看到的还是旧价格;ES同步任务挂了一下,新品在搜索结果里就是不出来。标题里这个"php方案 双写一致性最终一致方案",说的就是把这类问题从原理到代码完整地解决掉。

这篇文章适合正在处理缓存与数据库一致性问题、准备接ES等异构存储、或者想系统理解最终一致性的PHP后端开发。我会把几种主流方案(Cache Aside、延迟双删、消息队列异步化、Binlog订阅)从原理到PHP代码拆开讲,并附上我实际项目里验证过的参数和踩坑记录。代码可以直接抄,但建议先把"为什么"看明白,不然换个场景很容易抄翻车。

1. 双写一致性问题到底有多痛

1.1 三个典型场景,同一个病根

先说说我实际见过的三类双写场景。

第一类是缓存双写,最常见。商品表在MySQL里,详情页读取量太大,于是把商品信息做成Redis缓存。运营改个价格,你以为改完就完了?MySQL的update语句执行成功了,缓存的key还在,TTL可能是1小时,这一个小时里用户看到的全是旧价格。这是读多写少系统的典型病灶。

第二类是搜索同步。订单、商品、文章这些业务数据要进Elasticsearch做全文检索。你在代码里写完MySQL立刻去写ES,ES节点抖动一下,Index操作超时,MySQL那边又不好回滚。结果就是数据库里有这条记录,搜索里没有;运营在后台发布的文章前台搜不到。最难受的是这类问题不是必现的,而是偶发的,线上数据对不上账的时候才暴露出来。

第三类是跨服务双写。PHP这边更新完自己的库,还要调另一个服务的接口同步数据,或者发一条消息到MQ。对方服务挂了、消息丢了、消费超时,都可能造成两边数据不一致。这类问题往往要等到业务对账时候才能发现。

这三个场景的病根完全一样:一次业务操作要在多个存储里各自独立提交,任何一步失败或者时序错乱,数据就对不上了。MySQL单机有事务保证原子性,但Redis、ES、MQ这些外部系统不会跟你的事务一起提交,你没法用一套本地事务把它们全部包住。

1.2 强一致为什么不现实,最终一致才是出路

很多人第一反应是:能不能让MySQL和Redis在同一时刻一起变?理论上可以用分布式事务,比如二阶段提交(2PC)。但我劝你趁早死了这条心,至少PHP的常规架构里别这么干。

理由有三条。第一,2PC里有个协调者角色,协调者一旦挂掉,所有参与者都会阻塞住,可用性大打折扣,这正是分布式领域CAP理论里"用强一致换可用性"的老问题。第二,一次写操作要跨网络多次往返,线上请求延迟飙升。本来加缓存就是为了快,结果写路径反而变慢,得不偿失。第三,很多存储根本不支持真正的分布式事务协议,你没法让Redis或者ES跟MySQL做全局提交。

所以业界的共识是:承认"最终一致",也就是接受一个短暂的中间不一致窗口,但必须保证在几秒甚至几百毫秒内,数据能自动收敛一致。标题里说的"最终一致方案",核心思路就是围绕这个窗口做文章:要么缩小窗口,要么在窗口内把错误的读结果清掉,要么干脆不依赖瞬间一致性,靠对账机制兜底。接下来我按从简单到复杂的顺序,把这几种做法逐个拆开。

2. 方案选型:动手前先把账算明白

2.1 Cache Aside:最朴素的删缓存

先讲最基础也是最常用的Cache Aside模式,也叫旁路缓存。读的流程是:先读缓存,命中直接返回;没命中则查数据库,把结果写回缓存再返回。写的流程是:先更新数据库,然后删除缓存,而不是更新缓存。

为什么写的时候要"删"而不是"更新"?因为更新缓存会有一堆连锁问题。每次修改数据库字段,你都得同步修改缓存里的JSON结构,涉及字段多的时候维护成本很高;而且一个商品可能被多次修改,每次都要写Redis,纯属浪费。删掉之后让下次读请求自然重建缓存,也就是"懒加载",是成本最低、最不容易出错的方案。

但Cache Aside有一个众所周知的竞态漏洞。我描述一下这个时序:请求A读缓存,没命中,于是去查数据库,查到了旧值(比如价格100);此时请求B执行更新,把数据库改成200,并且删掉了缓存;然后请求A把自己查到的旧值100写回缓存。最终结果:数据库是200,缓存是100,而且再也没有一次删除能修正它。这个窗口虽然在单个请求里很短,但高并发下一定会发生,只是时间早晚的问题。

2.2 延迟双删:给竞态窗口踩刹车

针对上面那个竞态,业内最经济的补丁就是"延迟双删":更新数据库后先删一次缓存,等一个延迟时间(通常几百毫秒),再删一次。

第二次删除的作用是覆盖掉"请求A在B删除之后写入的旧值"。只要第一次删除到第二次删除之间的时间窗口,大于"一次缓存miss到回写完成"的时间,旧值就会被第二次删除清掉。实际操作中这个延迟不好精确测量,我一般取300到800毫秒的随机值,既保证覆盖绝大多数慢查询场景,又避免大量延迟任务在同一时刻集中执行。

延迟双删不是银弹。如果查数据库的那个请求特别慢,超过了你的延迟窗口,旧值还是会在第二次删除之后写回缓存。真要彻底消除竞态,得上版本号方案,这个后面会细说。所以延迟双删适合"绝大多数场景、低成本兜底",对极端严格要求的场景,要往上叠加其他机制。

2.3 消息队列异步化:把一次双写拆成两段

既然"一次请求里同时写两个存储"容易翻车,那就干脆拆开:主流程只写MySQL,写完立刻投递一条消息到MQ,由异步消费者去更新Redis或ES。这样双写就变成了"一个事务写 + 一个异步任务补",主流程成功与否只取决于MySQL,不再被外部系统拖累。

这个方案有个关键点:消息不能丢,也不能因为重复消费导致脏数据。丢消息的经典解法是"本地消息表",也叫Outbox模式——在写业务数据的同一个MySQL事务里,把要发的消息也插入一张消息表,二者同生共死;另起一个发布任务扫描消息表,把没发掉的消息投递到MQ,投递成功再标记为已发送。重复消费的问题则靠消费者幂等来解决,这个我放到第3节代码里展开。

用了MQ之后,一致性窗口主要取决于消费延迟和重试耗时。正常情况下几百毫秒内数据就能对齐;但如果消费者挂了,消息会在队列里堆积,窗口就会持续变长。所以这个方案必须搭配监控和重试上限,超限的消息进死信队列,再做人工或者脚本处理。

2.4 Binlog订阅:让MySQL当唯一事实源

再来一个更彻底的思路:不依赖应用代码里"删除缓存""发送消息"这些散落的动作,而是让中间件去订阅MySQL的Binlog,解析出每一行数据的INSERT、UPDATE、DELETE事件,再把这些事件投递到队列,由PHP消费者重建缓存或同步ES。

这么做最大的好处是应用代码彻底简化了。业务层不用关心缓存和ES,只管写MySQL;任何通过命令行、脚本改库的操作,只要产生了Binlog,同样能被感知和同步,不会漏掉。数据源唯一且权威,一致性的基础天然成立。

代价是要额外引入一套Binlog解析与同步的基础设施。MySQL要开启Binlog且用row格式;解析工具要处理主从切换、位点记录、断点续传;解析出来的数据变更事件是裸的行流,还需要在消费者侧做业务映射。对中小团队来说,这个方案有一个简化版:用现成的开源工具把Binlog变更转到MQ,PHP这边只需要写好消费逻辑。

2.5 方案对比与适用边界

我把这几种方案放到一张表里对比,方便你按项目情况选型。

方案一致性窗口引入成本应用层改动适合场景主要风险
Cache Aside极小但有竞态最低读写流程都改缓存与DB双写,读多写少偶发旧值回写缓存
延迟双删毫秒级低追加延迟删除动作上述场景且对脏读容忍度较低极端慢查询漏删
MQ异步化百毫秒到秒级中写路径+消费者同步ES、跨服务同步消息丢失、重复消费
Binlog订阅秒级可控高大幅简化异构存储多、历史包袱重基础设施复杂度

我自己给建议的话:缓存双写起步用Cache Aside加延迟双删,成本最低;一旦要同步ES或者跨服务,立刻上MQ异步化加本地消息表;如果团队能驾驭Binlog基础设施,并且业务对最终一致性窗口要求不高,直接用Binlog订阅做统一同步,能省掉后面无数个手写同步点。

3. 核心实现:PHP侧代码怎么落

3.1 Cache Aside加延迟双删的标准写法

先给最常用的"更新商品接口"一个可以直接抄的骨架。逻辑分三步:事务里更新MySQL;删一次Redis缓存;把延迟删任务投递到队列。

<?php final class ProductService { public function __construct( private readonly PDO $pdo, private readonly Redis $redis, private readonly MqProducer $producer, ) {} /** * 更新商品信息,采用 Cache Aside + 延迟双删 */ public function updateProduct(int $id, array $payload): void { // 第一步:事务更新 MySQL,业务数据原子提交 $this->pdo->beginTransaction(); try { $stmt = $this->pdo->prepare( 'UPDATE products SET name = :name, price = :price, updated_at = NOW() WHERE id = :id' ); $stmt->execute([ ':name' => $payload['name'], ':price' => $payload['price'], ':id' => $id, ]); $this->pdo->commit(); } catch (Throwable $e) { $this->pdo->rollBack(); throw $e; } // 第二步:先删一次缓存 $this->deleteProductCache($id); // 第三步:延迟双删,覆盖旧的读回写窗口 $this->producer->publish('delay.cache.clear', [ 'key' => "product:{$id}", 'delay_ms' => random_int(300, 800), ]); } private function deleteProductCache(int $id): void { $this->redis->del("product:{$id}"); } }

这里有几个细节值得说明。第一,删除缓存用的是DEL命令,而不是先读后写,因为DEL是原子的,不会产生中间状态。第二,延迟删任务的delay_ms要随机,防止所有商品同时触发二次删除时把Redis的连接打满。第三,如果项目里没有现成的延迟队列,可以用Redis的Sorted Set模拟,score存当前时间加延迟毫秒,一个后台脚本不断取最小score的任务,到点就执行删除。

<?php // Redis Sorted Set 实现的简易延迟队列 final class RedisDelayedQueue { public function __construct(private readonly Redis $redis) {} public function push(string $queue, string $payload, int $delayMs): void { $this->redis->zAdd($queue, microtime(true) * 1000 + $delayMs, $payload); } public function poll(string $queue): array { $now = microtime(true) * 1000; $rows = $this->redis->zRangeByScore($queue, '-inf', (string)$now, ['limit' => [0, 100]]); foreach ($rows as $row) { $removed = $this->redis->zRem($queue, $row); if ($removed === 1) { return [$queue, $row]; } } return [$queue, null]; } }

用zRem的返回值做并发抢占判断,保证多个worker不会把同一条延迟任务重复消费。这是我在实际项目里踩过坑之后补上的:不检查zRem结果的话,两个worker同时poll到同一条任务,就会执行两次延迟删除。删除本身是幂等的,但会浪费连接,任务量大的时候Redis会明显变慢。

提示:延迟双删的随机延迟一定要加。固定延迟值表面看起来更"可控",实际上会让所有延迟删任务在同一时间点涌入,Redis在高峰期会被多余的DEL命令拖慢。

3.2 基于本地消息表的可靠投递

说完了缓存双写,再看需要同步ES的场景。标准做法是"业务事务 + 本地消息表"合并提交,保证业务变更和待发送消息同时成功。

先看建表SQL:

CREATE TABLE `message_outbox` ( `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, `biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型,如product.sync', `biz_id` VARCHAR(64) NOT NULL COMMENT '业务主键,如商品ID', `payload` JSON NOT NULL COMMENT '消息体', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待发送 1已发送 2发送失败', `retry_count` INT NOT NULL DEFAULT 0 COMMENT '重试次数', `next_retry` DATETIME DEFAULT NULL COMMENT '下次重试时间', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY `idx_poll` (`status`, `next_retry`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

写入逻辑很直白:在更新商品的事务里,同时插入一条message_outbox记录,payload里存商品ID和操作类型。事务提交之后,一个独立的发布脚本循环扫描status为0的记录,把它们投递到MQ,投递成功就把状态置为1。

<?php public function createProductSyncMessage(PDO $pdo, int $productId, array $payload): void { $pdo->beginTransaction(); try { // 业务更新 $pdo->prepare('UPDATE products SET price = :price WHERE id = :id') ->execute([':price' => $payload['price'], ':id' => $productId]); // 同一事务内写本地消息表,保证要么都成功要么都失败 $pdo->prepare( 'INSERT INTO message_outbox (biz_type, biz_id, payload, status, next_retry) VALUES (:bt, :bi, :pl, 0, NOW())' )->execute([ ':bt' => 'product.sync', ':bi' => (string)$productId, ':pl' => json_encode($payload, JSON_UNESCAPED_UNICODE), ]); $pdo->commit(); } catch (Throwable $e) { $pdo->rollBack(); throw $e; } }

发布脚本的要诀是"先标记后投递",也就是先把记录状态从0改为1(或者加一把悲观锁),再真正发送到MQ。如果先发MQ再改状态,消息发成功了但状态没改,重启后会重复投递、消费者重复消费;如果先改状态再发MQ,那就漏消息了。两种错误里,宁可重复也不要漏,因为重复可以靠消费者幂等扛住,漏了只能靠对账发现。所以常规流程是:SELECT一条待发送记录,UPDATE它的status为1并记录当前worker实例,然后发送。发送失败时候,把status改回0、retry_count加1、next_retry加到退避时间。

3.3 消费者处理与幂等

MQ投递通常是"至少一次"语义,也就是说消息可能重复。消费者必须幂等,常见做法是建立一个去重表,用业务主键加消息版本做唯一键。

<?php final class ProductEsSyncConsumer { public function __construct( private readonly PDO $pdo, private readonly ElasticsearchClient $es, ) {} public function handle(array $message): void { $productId = $message['product_id']; $bizKey = "product.sync:{$productId}:{$message['version']}"; // 幂等去重:业务主键 + 消息版本作为唯一键 $inserted = $this->pdo->prepare( 'INSERT IGNORE INTO consume_dedupe (biz_key, consume_time) VALUES (:key, NOW())' )->execute([':key' => $bizKey]); if ($inserted === 0) { return; // 已处理过,直接跳过 } try { $product = $this->loadProduct($productId); if ($product === null) { $this->es->delete(['index' => 'products', 'id' => $productId]); } else { $this->es->index([ 'index' => 'products', 'id' => $productId, 'body' => $this->buildDocument($product), ]); } } catch (Throwable $e) { // 回滚去重记录并抛出异常,让MQ触发重投 $this->pdo->prepare('DELETE FROM consume_dedupe WHERE biz_key = :key') ->execute([':key' => $bizKey]); throw $e; } } }

注意一个细节:去重记录要在整个消费流程处理成功后再提交,不能让它先于"ES同步"成功。如果先Insert IGNORE成功然后ES同步失败,消息重投后代码里直接return,就会漏掉这次同步。所以上面的异常分支要把去重记录删掉,让消息在MQ侧触发重投后能再次执行。这个顺序问题,是我用"先插去重表再同步ES"的初版方案上线后,被线上漏同步数据逼出来的教训。

3.4 Binlog订阅的PHP处理方式

如果走Binlog订阅路线,PHP侧的主要工作就变成"消费行变更事件并更新下游"。用开源工具把Binlog解析并转成结构化的变更事件投到MQ之后,消费者的代码大概长这样:

<?php final class BinlogRowSyncConsumer { public function handle(array $rowChange): void { $database = $rowChange['database']; $table = $rowChange['table']; $action = $rowChange['action']; // insert | update | delete $primary = $rowChange['primary_key'] ?? null; if ($database !== 'shop' || $table !== 'products' || $primary === null) { return; } $cacheKey = "product:{$primary}"; if ($action === 'delete') { $this->redis->del($cacheKey); $this->es->delete(['index' => 'products', 'id' => $primary]); return; } // insert / update 统一重建缓存和索引 $fresh = $this->loadProductById((int)$primary); if ($fresh === null) { return; } $this->redis->setex($cacheKey, 3600, json_encode($fresh, JSON_UNESCAPED_UNICODE)); $this->es->index([ 'index' => 'products', 'id' => $primary, 'body' => $this->buildDocument($fresh), ]); } }

Binlog方案下,缓存过期时间哪怕设短一些也没关系,因为新增和修改都会由Binlog事件主动刷新缓存,不再依赖读请求触发重建。这套模式对"缓存里数据长期不过期、靠Binlog推送保新鲜"的架构特别友好。不过要提醒一句:如果ES同步失败,Binlog事件又已经消费掉了,数据就会停在上一个版本。所以这个方案一定要配"失败重试队列 + 定期对账脚本",否则同步链路任何一个环节静默失败,问题会一直藏到用户投诉才暴露。

4. 实操细节与参数调优

4.1 重试策略:退避、次数、死信

所有依赖MQ的最终一致方案,都绕不开重试。我把重试策略总结成"三次递进":业务代码遇到可重试异常时,第一次立即重试;第二次等待指数退避,比如1秒、2秒、4秒;超过最大次数(我一般定5次)就把消息丢进死信队列,触发告警,由人工或对账脚本介入。

死信队列很重要,别因为它名字里带个"死"字就忽视它。它其实是整个最终一致体系的兜底阀:消息在这里堆积,说明有系统性问题,你要第一时间看到告警去处理。我见过不少团队只做重试不做死信,结果消费端一直失败的消息无限重投,把MQ的连接和消费线程全部占满,拖垮了整个应用。所以务必设置retry上限,并给死信消费者配上告警,让问题在萌芽阶段就暴露出来。

4.2 延迟窗口和过期时间怎么设

延迟双删的第二次删除时间,选多少合适?我的建议是不要拍脑袋定死,先在测试环境压一遍"缓存miss到回写完成"的耗时分布,取99分位值,再乘上2到3倍的安全系数。很多团队直接复制网上的500毫秒,如果你的数据库查询本身就是慢,500毫秒可能不够;反过来业务量极大的场景,固定500毫秒会让延迟任务在某一秒集中执行,所以要加随机抖动。

缓存的过期时间设置也要配合方案。如果走Cache Aside加延迟双删,TTL建议不要超过15分钟,过长会让脏数据存活时间大幅延长,对账也不好修。如果走Binlog订阅方案,TTL反而可以适当加长甚至接近永久,因为数据新鲜度由Binlog推送保底,过期时间只作为最终兜底。两种思路的TTL策略完全不同,换方案的时候别忘了调。

4.3 缓存防护三件套

缓存和数据库的一致性搞定了,还得防住缓存的三个经典事故:穿透、击穿、雪崩。穿透是请求一个根本不存在的key,缓存永远miss,每次都打到数据库;击穿是一个热点key过期的瞬间,大量请求同时打库;雪崩是大量key在同一时间过期,数据库被一波流量冲垮。

对应手段分别是:穿透用布隆过滤器,或者缓存空值(空值也写缓存,TTL短一点,比如60秒);击穿用互斥重建,也就是首次miss时让一个请求去查库重建,其余请求短暂等待后读新缓存;雪崩给过期时间加随机后缀,比如base TTL 300秒,再随机加0到60秒,让过期时间点散开。这三件套和双写一致性是配合关系,缺一个缓存层就可能成为新的不稳定点。我之前有个线上事故就是只做了双写、没做击穿防护,一个热门商品缓存过期,几十个并发同时回源数据库,把连接池打满,连带MQ消费也超时,最后整条链路雪崩。

5. 常见问题与排查实录

5.1 缓存里一直是旧值,日志却显示删过

遇到过好几次这种假象:代码里明明执行了del,缓存里却还是旧数据。排查后的原因通常有两个。第一个是删除用的连接和读用的连接不在同一个Redis实例,测试环境多套配置串了;第二个更隐蔽,是主从延迟——PHP直连Redis主节点删了key,但读请求通过从节点读到了旧副本,然后从节点的旧值又通过某种方式回填到了主库。这个问题在开启Redis主从复制、做读写分离的项目里比较常见。

解决思路:优先保证读写都走同一个Redis实例;如果架构上确实需要读写分离,可以考虑让缓存key带版本号,从节点读到旧版本时主动回源数据库校验。虽然成本高一点,但能杜绝这类脏读。

5.2 消息重复消费导致数据被覆盖

MQ的至少一次语义决定了重复消息早晚会出现。最典型的场景是:消费者处理完ES同步,正要ACK时网络断了,MQ超时后重投,结果消费者又执行了一次同步。同步本身幂等还好,但如果两次消息携带的是不同的数据版本,后到的旧版本可能覆盖新版本。比如改价操作发了两次消息,第一次带价格100,第二次带价格200,网络乱序导致200先到、100后到,ES里最终是100。

解法有两个层面。消息层面,给每条消息带一个递增的版本号或者业务时间戳,消费者比较版本,只接受更新的版本,这也可以理解为"业务幂等"而非"接口幂等"。数据层面,ES的文档里存一个sync_version字段,写入前比较当前文档里的版本与消息里的版本,旧的不写。这个方案我用了之后,再也没出现过旧数据覆盖新数据的问题。

5.3 数据对账脚本怎么设计才不伤库

不管前面的方案做得多完美,我都建议留一个对账兜底。对账脚本的职责是定期扫描MySQL全量数据,和缓存、ES逐条比对,发现不一致就修复。但全量扫描很容易把数据库打挂,实际项目里必须控制节奏。

我的做法是分批游标扫描,每批1000条,处理完一批做短时sleep,限速避免压垮主库;比对时只比较关键字段的摘要,比如把MySQL行转成JSON后算摘要,跟缓存值做摘要比对,减少传输和计算;修复动作只针对缓存存在且内容不一致的记录,缓存已过期的记录直接跳过,因为下次读请求会自然重建。对账脚本最好在凌晨低峰期运行,并把每次修复的条数记录下来。修复量突然变大往往是前面某个环节出了问题,要及时去看告警。

6. 踩坑心得与个人体会

讲了这么多,最后聊聊我自己的体会。双写一致性没有一劳永逸的银弹,方案是分层组合出来的:缓存双写用Cache Aside加延迟双删解决绝大多数问题;涉及ES和跨服务同步就上本地消息表加MQ,用幂等和死信保证可靠;团队有基础设施能力、业务又对一致性窗口不太敏感时,Binlog订阅是把复杂散点收拢成统一链路的最优解。每个方案都有自己的适用边界,选型前先问清楚业务能容忍多久的数据不一致,再决定投入多少成本。

实际操作中,我最深的感受有三件事。第一,任何异步方案都必须配重试上限和对账兜底,千万别指望链路永远健康,死信队列和告警不是奢侈品,是必需品。第二,方案切换的时候,缓存TTL策略、连接路由这些细节要一起改,不然新旧逻辑混用会制造出更难排查的诡异问题。第三,把"为什么这么设计"想明白再动手,比抄代码重要得多。我接手过一个项目,代码里延迟双删、消息表、binlog订阅三套全上了,但因为各自的参数互不配套,数据照样乱。工具多不是坏事,坏的是不知道该让谁在什么场景下起作用。

上面这些代码和参数,你直接拿去用,但记得先在自己的压测环境里验证一遍耗时分布和并发表现,再决定最终数值。我在这里分享的都是被线上事故教训过之后沉淀下来的东西,希望你能少走几段弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询