本文目录导读:

PHP缓存与数据库双写一致性实战:从原理到代码的终极指南**
目录导读
- 为什么双写会引发一致性灾难?
- 四大主流双写策略深度对比
- Cache-Aside(旁路缓存)
- Write-Through(直写)
- Write-Back(回写)
- 延迟双删(Delayed Double Delete)
- PHP代码实战:Redis + MySQL 高并发双写方案
- 架构级兜底:消息队列异步补偿
- 必须避开的5个隐藏陷阱
- 高频面试问答(Q&A)
为什么双写会引发一致性灾难?
在PHP高并发系统中,缓存(Redis/Memcached)与数据库(MySQL)的写入顺序不当,会导致数据不一致,经典的错误场景是:
- 先更新数据库,再删除缓存 → 若删除失败,缓存脏数据残留。
- 先删除缓存,再更新数据库 → 并发读请求瞬间击穿缓存,把旧数据回填。
核心矛盾在于缓存与数据库是两套独立存储,无法原子性同步,根据CAP理论,我们只能在“最终一致性”上做文章。
四大主流双写策略深度对比
Cache-Aside(工业最常用)
流程:读:先查缓存→未命中→查库→回填缓存;写:先更新库→删除缓存。
优点:实现简单,缓存利用率高。
缺陷:高并发下存在“竞争窗口”(如删缓存瞬间,旧数据被读线程回填)。
Write-Through(同步直写)
流程:写请求先写缓存,缓存同步写库,成功后才返回。
优点:强一致性(非分布式事务下)。
缺陷:写入延迟高,缓存故障会阻塞业务。
Write-Back(异步回写)
流程:只写缓存,立即返回;后台批量异步刷入数据库。
优点:写入性能极佳。
缺陷:宕机时缓存数据丢失风险高。
延迟双删(实战首选改良)
核心公式:
更新数据库 → 休眠(例如500ms) → 删除缓存 → 重试机制
原理:休眠时间 > 业务读请求填充缓存的最长时间,确保下一次读请求强制拉取新库数据。
注意:休眠时间需根据业务测压设定,过短无效,过长影响吞吐。
PHP代码实战:Redis + MySQL 高并发双写方案
以下代码结合延迟双删 + 补偿任务,兼顾性能与最终一致性:
<?php
class CacheDoubleWriter {
private $redis;
private $pdo;
private $deleteRetry = 3; // 删除重试次数
public function updateUser($userId, $data) {
// 1. 先更新数据库
$pdoStmt = $this->pdo->prepare("UPDATE users SET name=? WHERE id=?");
$pdoStmt->execute([$data['name'], $userId]);
// 2. 延迟删除缓存(用消息队列或sleep实现)
$this->delayedDeleteCache("user:{$userId}", 500); // 500ms
// 3. 异步补偿:记录操作日志,若删除失败则重试
$this->logCompensateTask("user:{$userId}");
}
private function delayedDeleteCache($key, $ms) {
// 方案A:简单sleep(适用中小流量)
usleep($ms * 1000);
$this->deleteWithRetry($key);
// 方案B: 投递到RabbitMQ延迟队列(生产推荐)
// rabbitmq::delayedPublish('cache_clean', $key, $ms);
}
private function deleteWithRetry($key) {
for ($i=0; $i<$this->deleteRetry; $i++) {
if ($this->redis->del($key)) return;
usleep(200000); // 200ms重试
}
// 记录失败,交给Worker脚本轮询删除
$this->failedKeys()->push($key);
}
}
关键点:
- 必须用连接池连接Redis,避免频繁建立连接。
- 数据库更新后,立即触发异步删除,而非同步死等。
架构级兜底:消息队列异步补偿
即使代码逻辑正确,仍可能因网络抖动导致删除失败,此时需引入可靠事件模式:
- 业务操作日志表记录“更新任务”。
- 将删除缓存的任务发送至RabbitMQ延迟队列。
- 消费者收到消息后执行删除,失败则进入死信队列重试。
- 定时扫描补偿表,如无死信超过10分钟即报警。
伪代码:
// 生产者(业务层)
$mq->publish('cache_clean', ['key'=>'user:123'], $delay=500);
// 消费者(Worker)
public function cleanCache($job) {
$key = $job->data['key'];
if (!Redis::del($key)) {
$job->release(30); // 30秒后重试
}
}
必须避开的5个隐藏陷阱
- 睡眠时间设太短:导致旧数据回填,应压测“读请求最大填充耗时”的1.5倍。
- 未处理缓存穿透:查询不存在的数据需存Null值并设置短TTL。
- 主从延迟:更新从库后立即删缓存无效,需等待主从同步时间。
- 大key删除阻塞Redis:用
unlink命令异步删除。 - 忽略事务边界:数据库更新失败不能触发缓存删除,必须用事务包裹。
高频面试问答(Q&A)
Q1:双写时为什么不能先删缓存再更新数据库?
A:并发场景下,读写线程交错会互相污染:删缓存后,读线程立即从库拉取旧数据回填缓存,随后写线程更新库,导致缓存永久残留旧数据。
Q2:延迟双删的“延迟时间”如何确定?
A:需统计业务中从执行读操作到回填缓存的最大耗时(通常为1-5ms),延迟时间建议设置为最大耗时的2倍,且不能超过业务可接受的最终一致窗口。
Q3:如果缓存删除多次重试仍失败怎么办?
A:将失败的key发送到消息队列死信队列,由独立Worker扫描Redis中的占位标记(如pending:key),定期强制删除缓存。
Q4:为什么不用分布式锁保证强一致?
A:分布式锁(如RedLock)会引入高延迟和复杂的锁管理,降低吞吐量,对于绝大多数读多写少场景,最终一致性+补偿机制是性能和正确性的最佳平衡。
Q5:如何验证最终一致性是否达成?
A:设计对账任务:每小时批量比对缓存中记录的版本号与数据库的update_time,不一致则重置缓存。
通过以上策略,PHP系统可以在高并发下做到“缓存与数据库最终一致”,同时保持99.99%的高可用性。没有完美的一致性,只有最适合业务场景的技术选型。