PHP缓存和数据库双写

wen PHP项目 2

本文目录导读:

PHP缓存和数据库双写

  1. 目录导读
  2. 为什么双写会引发一致性灾难?
  3. 四大主流双写策略深度对比
  4. PHP代码实战:Redis + MySQL 高并发双写方案
  5. 架构级兜底:消息队列异步补偿
  6. 必须避开的5个隐藏陷阱
  7. 高频面试问答(Q&A)

PHP缓存与数据库双写一致性实战:从原理到代码的终极指南**


目录导读

  1. 为什么双写会引发一致性灾难?
  2. 四大主流双写策略深度对比
    • Cache-Aside(旁路缓存)
    • Write-Through(直写)
    • Write-Back(回写)
    • 延迟双删(Delayed Double Delete)
  3. PHP代码实战:Redis + MySQL 高并发双写方案
  4. 架构级兜底:消息队列异步补偿
  5. 必须避开的5个隐藏陷阱
  6. 高频面试问答(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,避免频繁建立连接。
  • 数据库更新后,立即触发异步删除,而非同步死等。

架构级兜底:消息队列异步补偿

即使代码逻辑正确,仍可能因网络抖动导致删除失败,此时需引入可靠事件模式

  1. 业务操作日志表记录“更新任务”。
  2. 将删除缓存的任务发送至RabbitMQ延迟队列。
  3. 消费者收到消息后执行删除,失败则进入死信队列重试。
  4. 定时扫描补偿表,如无死信超过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. 睡眠时间设太短:导致旧数据回填,应压测“读请求最大填充耗时”的1.5倍。
  2. 未处理缓存穿透:查询不存在的数据需存Null值并设置短TTL。
  3. 主从延迟:更新从库后立即删缓存无效,需等待主从同步时间。
  4. 大key删除阻塞Redis:用unlink命令异步删除。
  5. 忽略事务边界:数据库更新失败不能触发缓存删除,必须用事务包裹。

高频面试问答(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%的高可用性。没有完美的一致性,只有最适合业务场景的技术选型

抱歉,评论功能暂时关闭!