一、缓存一致性问题的由来
在Web开发中,缓存是提升系统性能的利器,但随之而来的缓存一致性问题却让人头疼。比如,用户更新了个人资料,但页面上显示的仍然是旧数据,这就是典型的缓存不一致问题。
为什么会出现这种情况?因为缓存和数据库是两个独立的数据源。当数据发生变化时,如果只更新了数据库而忘记更新缓存,或者更新缓存的时机不对,就会导致用户看到过期数据。
举个例子:
// 技术栈:PHP + Redis + MySQL
// 用户更新昵称(错误示例)
function updateUserNickname($userId, $newNickname) {
// 只更新数据库,忘记更新缓存
$db->query("UPDATE users SET nickname = ? WHERE id = ?", [$newNickname, $userId]);
// 其他用户读取时可能拿到旧缓存
}
二、常见的缓存更新策略
2.1 Cache Aside Pattern(旁路缓存)
这是最常用的策略,核心逻辑是:
- 读:先查缓存,命中则返回;未命中则查库并回填缓存
- 写:先更新数据库,再删除缓存
// 读取用户数据
function getUser($userId) {
$cacheKey = "user:$userId";
$data = $redis->get($cacheKey);
if ($data === false) {
$data = $db->query("SELECT * FROM users WHERE id = ?", [$userId]);
$redis->setex($cacheKey, 3600, json_encode($data)); // 设置1小时过期
}
return json_decode($data, true);
}
// 更新用户数据
function updateUser($userId, $newData) {
$db->query("UPDATE users SET ... WHERE id = ?", [$userId]);
$redis->del("user:$userId"); // 关键:删除缓存
}
2.2 Write Through(直写模式)
所有写操作都先经过缓存,由缓存同步更新数据库。这种模式一致性更好,但对缓存系统要求较高。
三、缓存失效策略的陷阱
3.1 先更新数据库还是先删缓存?
经典的"双写问题":
- 如果先删缓存再更新数据库:在删除后、更新前的间隙,其他请求可能把旧数据重新放入缓存
- 如果先更新数据库再删缓存:虽然更可靠,但极端情况下仍可能不一致
解决方案是引入延迟双删:
function updateProduct($productId, $data) {
$redis->del("product:$productId"); // 第一次删除
$db->query("UPDATE products..."); // 更新数据库
usleep(10000); // 延迟10ms
$redis->del("product:$productId"); // 第二次删除
}
3.2 缓存雪崩与击穿预防
雪崩:大量缓存同时失效导致请求直接打到数据库
解决方案:给缓存过期时间加随机值$redis->setex($key, 3600 + rand(0, 600), $value); // 基础1小时+随机10分钟击穿:热点key失效瞬间遭遇高并发
解决方案:互斥锁function getHotData($key) { if (!$data = $redis->get($key)) { if ($lock = $redis->setnx("lock:$key", 1, 10)) { // 获取分布式锁 $data = fetchFromDB(); $redis->setex($key, 300, $data); $redis->del("lock:$key"); } else { usleep(100000); // 等待100ms重试 return getHotData($key); } } return $data; }
四、实战中的进阶方案
4.1 数据库Binlog监听
通过监听MySQL的binlog变化来触发缓存更新,这是最彻底的一致性方案:
// 伪代码:使用Canal监听binlog
$canalConn = new CanalConnector();
$canalConn->subscribe(".*\\..*");
while (true) {
$message = $canalConn->get();
foreach ($message->getEntries() as $entry) {
if ($entry->eventType == UPDATE_EVENT) {
$table = $entry->header->tableName;
$id = $entry->rowChange->row->afterColumns[0]->value;
$redis->del("$table:$id"); // 删除对应缓存
}
}
}
4.2 版本号控制
给缓存数据增加版本标识,读取时校验版本:
function getWithVersion($key) {
list($version, $data) = explode('|', $redis->get($key));
$dbVersion = $db->query("SELECT version FROM cache_versions WHERE key = ?", [$key]);
if ($version != $dbVersion) {
$data = refreshData($key);
}
return $data;
}
五、不同场景下的选择建议
- 读多写少:适合Cache Aside + 合理过期时间
- 写多读少:考虑Write Through或直接禁用缓存
- 金融级一致性:需要Binlog监听+事务消息
- 超高并发:引入本地缓存+多级缓存架构
记住没有银弹,需要根据业务特点选择折中方案。比如社交媒体的头像更新可以接受短暂不一致,但电商库存必须强一致。
六、避坑指南
- 不要使用"更新数据库后立即更新缓存"策略,这在并发写时会导致缓存脏数据
- 缓存时间不宜过长,一般建议1-30分钟,高频数据可适当延长
- 删除缓存可能失败,需要重试机制或设置合理的过期时间兜底
- 监控缓存命中率,低于90%就需要优化策略
// 带重试的缓存删除
function safeDel($key, $retries = 3) {
while ($retries-- > 0) {
if ($redis->del($key)) return true;
usleep(100000); // 等待100ms
}
// 记录监控告警
monitorLog("cache_del_failed", $key);
return false;
}
通过以上方法,相信你能在性能和一致性之间找到最佳平衡点。缓存就像做菜时的调味料,用得好能让系统性能大放异彩,用不好就会毁掉整锅好汤。
评论