PHP项目缓存数据如何同步数据库:从原理到落地的完整指南
目录导读
- 为什么会出现缓存与数据库数据不一致?
- 常见缓存同步策略对比与选型
- PHP项目中的五种核心同步方案
- 实战:基于Redis+MySQL的缓存同步代码示例
- 如何避免缓存穿透、击穿、雪崩?
- 常见问题问答(QA)
- 总结与最佳实践建议
为什么会出现缓存与数据库数据不一致?
在PHP高并发项目中,为了提升读取性能,我们通常使用Redis、Memcached等缓存存储频繁访问的数据,但缓存本质是数据库的“快照副本”,当数据库数据发生更新(增、删、改)时,如果缓存没有同步更新,就会出现缓存脏数据,表现为用户看到的数据与真实数据库不一致。

典型场景:
- 用户修改个人信息,缓存中的旧数据未被清除
- 电商库存扣减时,缓存中的库存数量滞后于数据库
- 文章阅读量频繁更新,缓存与数据库数值偏差
核心矛盾:数据库是持久化存储,缓存是临时快照,两者必须通过某种机制保持最终一致性。
常见缓存同步策略对比与选型
| 策略名称 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Cache-Aside | 读时回写,写时删除 | 实现简单 | 可能产生短时不一致 | 大多数业务 |
| Read-Through | 缓存层自动加载数据库数据 | 对业务透明 | 需依赖缓存中间件 | 通用场景 |
| Write-Through | 写操作同时写入缓存和数据库 | 强一致性 | 写延迟增加 | 对一致性要求极高 |
| Write-Behind | 先写缓存,异步批量写数据库 | 写性能高 | 存在数据丢失风险 | 日志、计数场景 |
| 双删策略 | 写数据库前后各删一次缓存 | 提升一致性概率 | 实现复杂度高 | 高并发更新场景 |
推荐:对于绝大多数PHP项目,Cache-Aside模式 + 延迟双删是性价比最高的方案。
PHP项目中的五种核心同步方案
手动清除缓存(Cache-Aside经典模式)
// 更新数据库后立即删除缓存
$pdo->exec("UPDATE users SET name='张三' WHERE id=1");
$redis->del("user:1");
缺点:高并发下,一个线程删除缓存后,另一个线程可能读旧数据并重建缓存。
延迟双删(解决缓存重建竞争)
$pdo->exec("UPDATE users SET name='张三' WHERE id=1");
$redis->del("user:1");
// 延迟100ms再次删除
\Swoole\Coroutine::sleep(0.1);
$redis->del("user:1");
原理:第二次删除覆盖了第一次删除到重建缓存之间的时间差。
消息队列异步同步
使用RabbitMQ或Redis Stream,将更新事件发送到队列:
// 写操作
$pdo->exec("UPDATE users SET name='张三' WHERE id=1");
$redis->lPush("cache:sync:queue", json_encode(['table'=>'users', 'id'=>1]));
// 消费者进程
while($msg = $redis->rPop("cache:sync:queue")) {
$data = json_decode($msg, true);
$redis->del("{$data['table']}:{$data['id']}");
}
数据库Binlog监听
通过Canal或Maxwell监听MySQL变更日志,实时推送至Redis:
- 优点:与业务代码解耦
- 缺点:需要额外部署中间件
缓存过期时间兜底
即使同步失败,设置合理过期时间(如1小时),保证最终一致性:
$redis->setex("user:1", 3600, $data); // 1小时自动过期
实战:基于Redis+MySQL的缓存同步代码示例
以下是一个完整的用户信息更新同步函数:
class UserCacheSync {
private $pdo;
private $redis;
public function updateUser(int $id, array $data): bool {
$this->pdo->beginTransaction();
try {
// 1. 更新数据库
$sql = "UPDATE users SET name=:name, email=:email WHERE id=:id";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([
':name' => $data['name'],
':email' => $data['email'],
':id' => $id
]);
// 2. 第一次删除缓存
$cacheKey = "user:{$id}";
$this->redis->del($cacheKey);
$this->pdo->commit();
// 3. 延迟300ms(根据业务调整)后再次删除
usleep(300000); // 300ms
$this->redis->del($cacheKey);
return true;
} catch (\Exception $e) {
$this->pdo->rollBack();
// 记录日志,告警
Log::error("User update failed: " . $e->getMessage());
return false;
}
}
}
关键点:
- 事务保证数据库更新原子性
- 两次删除间隔根据网络IO调整
- 失败时记录日志并人工介入
如何避免缓存穿透、击穿、雪崩?
在同步缓存的过程中,这三个问题同样需要防范:
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 穿透 | 请求不存在的数据,直接打到数据库 | 布隆过滤器 + 缓存空对象(短过期) |
| 击穿 | 热点key突然失效,大量请求直击数据库 | 互斥锁(SETNX)重建缓存 |
| 雪崩 | 大量key同时过期 | 过期时间加随机值 + 多级缓存 |
代码示例:使用互斥锁防止缓存击穿
$cacheKey = "hot:product:1";
$data = $redis->get($cacheKey);
if (!$data) {
if ($redis->setnx("lock:{$cacheKey}", 1)) {
$redis->expire("lock:{$cacheKey}", 5);
$data = $pdo->query("SELECT * FROM products WHERE id=1");
$redis->setex($cacheKey, 3600, $data);
$redis->del("lock:{$cacheKey}");
} else {
usleep(10000);
return $this->getProduct(1); // 重试
}
}
常见问题问答(QA)
Q1: 延迟双删中的延迟时间如何确定?
A: 通常取业务数据库主从同步延迟的2倍,例如主从延迟平均100ms,则延迟设为200-300ms,也可以通过测量从执行删除到查询重建的时间估算。
Q2: 如果缓存删除失败该怎么办?
A: 采用失败重试机制:
- 记录删除失败的key到本地文件或独立队列
- 定时任务重新删除
- 使用Redis本身的消息队列确保最终删除
Q3: 缓存同步可以做到强一致性吗?
A: 在分布式系统中,CAP理论限制无法达到严格强一致性,可以通过以下方式近似:
- 使用分布式锁(如RedLock)保护读写操作
- 采用数据库乐观锁(版本号)在缓存中校验
- 接受秒级延迟(最终一致性)
Q4: 方案一(手动删除)和方案三(消息队列)如何选择?
A:
- 并发量<1000QPS,数据一致性要求一般:方案一即可
- 并发量>5000QPS,需要解耦:方案三更合适
- 对数据一致性要求极高:方案二(延迟双删)+ 方案五(短过期)组合
总结与最佳实践建议
核心原则
- 缓存是数据库的副本,不是源数据
- 写操作先更新数据库,后删除缓存
- 接受最终一致性,通过过期时间兜底
推荐技术栈
- PHP框架:Laravel/Lumen 自带缓存标签功能,简化同步
- Redis:使用Lua脚本原子化删除操作
- 监控:redis-cli monitor + slowlog 追踪缓存操作
性能调优
- 尽量小批量删除,避免DEL大key阻塞
- 对LIST结构缓存,使用UNLINK替代DEL(异步释放内存)
- 冷数据主动过期,缓存中只保留热点数据
最后提醒
不要试图让缓存和100%实时一致,在99.9%的场景中,最终一致性配合合理的监控告警,就是最优秀的工程方案,投入过多的成本追求完全一致,往往得不偿失。
延伸阅读:
- Redis官方缓存淘汰策略文档
- MySQL Binlog同步中间件Canal实战
- 布隆过滤器在PHP中的使用
(全文完)