一、缓存一致性问题的由来

在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;
}

五、不同场景下的选择建议

  1. 读多写少:适合Cache Aside + 合理过期时间
  2. 写多读少:考虑Write Through或直接禁用缓存
  3. 金融级一致性:需要Binlog监听+事务消息
  4. 超高并发:引入本地缓存+多级缓存架构

记住没有银弹,需要根据业务特点选择折中方案。比如社交媒体的头像更新可以接受短暂不一致,但电商库存必须强一致。

六、避坑指南

  1. 不要使用"更新数据库后立即更新缓存"策略,这在并发写时会导致缓存脏数据
  2. 缓存时间不宜过长,一般建议1-30分钟,高频数据可适当延长
  3. 删除缓存可能失败,需要重试机制或设置合理的过期时间兜底
  4. 监控缓存命中率,低于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;
}

通过以上方法,相信你能在性能和一致性之间找到最佳平衡点。缓存就像做菜时的调味料,用得好能让系统性能大放异彩,用不好就会毁掉整锅好汤。