资讯详情

PHP后端双写一致性解决:缓存与最终一致方案全解析

📅 2026/10/13 4:09:54 | 华诺云谱 👁 阅读
PHP后端双写一致性解决:缓存与最终一致方案全解析
做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立刻去写ESES节点抖动一下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变更转到MQPHP这边只需要写好消费逻辑。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的场景。标准做法是业务事务 本地消息表合并提交保证业务变更和待发送消息同时成功。先看建表SQLCREATE 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订阅三套全上了但因为各自的参数互不配套数据照样乱。工具多不是坏事坏的是不知道该让谁在什么场景下起作用。上面这些代码和参数你直接拿去用但记得先在自己的压测环境里验证一遍耗时分布和并发表现再决定最终数值。我在这里分享的都是被线上事故教训过之后沉淀下来的东西希望你能少走几段弯路。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑