一、缓存一致性问题的本质

在Web开发中,缓存就像是你家门口的快递柜。它能让你快速拿到包裹(数据),但有时候快递员(数据库)更新了包裹内容,而快递柜里的还是旧版本。这就是典型的缓存一致性问题。

想象这样一个场景:你在电商网站修改了商品价格,数据库已经更新,但用户看到的还是缓存中的旧价格。这种情况轻则影响用户体验,重则造成资金损失。

PHP技术栈下,我们常用Redis作为缓存。来看个典型例子:

// 获取商品信息 - 先读缓存,没有再查数据库
function getProduct($id) {
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);
    
    $cacheKey = "product:" . $id;
    $product = $redis->get($cacheKey);
    
    if (!$product) {
        // 缓存未命中,查询数据库
        $pdo = new PDO('mysql:host=localhost;dbname=shop', 'user', 'pass');
        $stmt = $pdo->prepare("SELECT * FROM products WHERE id = ?");
        $stmt->execute([$id]);
        $product = $stmt->fetch(PDO::FETCH_ASSOC);
        
        // 写入缓存,设置1小时过期
        $redis->setex($cacheKey, 3600, json_encode($product));
    } else {
        $product = json_decode($product, true);
    }
    
    return $product;
}

这个简单的缓存读取逻辑已经埋下了不一致的隐患。当商品信息更新时,如果没有正确处理缓存,就会导致数据不一致。

二、缓存更新通知机制

解决一致性问题的第一道防线是建立有效的缓存更新通知机制。这就像小区物业在快递柜更新时会给你发短信提醒一样。

在PHP中,我们可以通过数据库触发器+消息队列实现:

// 数据库触发器示例(MySQL)
DELIMITER //
CREATE TRIGGER after_product_update
AFTER UPDATE ON products
FOR EACH ROW
BEGIN
    INSERT INTO mq_messages(queue, payload)
    VALUES('cache_update', CONCAT('product:', NEW.id));
END//
DELIMITER ;

// PHP消费者脚本
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

$pdo = new PDO('mysql:host=localhost;dbname=shop', 'user', 'pass');

while (true) {
    // 从消息队列获取更新通知
    $stmt = $pdo->query("SELECT payload FROM mq_messages WHERE queue = 'cache_update' LIMIT 1 FOR UPDATE SKIP LOCKED");
    $message = $stmt->fetch(PDO::FETCH_ASSOC);
    
    if ($message) {
        $cacheKey = $message['payload'];
        
        // 删除对应缓存
        $redis->del($cacheKey);
        
        // 处理完成后删除消息
        $pdo->exec("DELETE FROM mq_messages WHERE payload = '$cacheKey'");
    }
    
    sleep(1); // 避免CPU空转
}

这种方案的优点是实时性较好,但实现相对复杂。对于小型项目,可以采用更简单的主动删除策略:

// 更新商品信息时主动删除缓存
function updateProduct($id, $data) {
    $pdo = new PDO('mysql:host=localhost;dbname=shop', 'user', 'pass');
    
    // 开启事务
    $pdo->beginTransaction();
    
    try {
        // 更新数据库
        $stmt = $pdo->prepare("UPDATE products SET name=?, price=? WHERE id=?");
        $stmt->execute([$data['name'], $data['price'], $id]);
        
        // 删除缓存
        $redis = new Redis();
        $redis->connect('127.0.0.1', 6379);
        $redis->del("product:" . $id);
        
        $pdo->commit();
        return true;
    } catch (Exception $e) {
        $pdo->rollBack();
        return false;
    }
}

三、数据库缓存双写策略

双写策略就像同时给两个人发消息,确保信息同步。在缓存场景下,我们需要同时处理数据库和缓存的写入。

PHP中实现双写策略需要注意执行顺序。推荐先更新数据库,再更新缓存:

function saveProduct($product) {
    $pdo = new PDO('mysql:host=localhost;dbname=shop', 'user', 'pass');
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);
    
    // 先更新数据库
    $stmt = $pdo->prepare("INSERT INTO products (name, price) VALUES (?, ?) 
                          ON DUPLICATE KEY UPDATE name=VALUES(name), price=VALUES(price)");
    $stmt->execute([$product['name'], $product['price']]);
    
    $productId = $product['id'] ?? $pdo->lastInsertId();
    
    // 再更新缓存
    $cacheKey = "product:" . $productId;
    $redis->setex($cacheKey, 3600, json_encode($product));
    
    return $productId;
}

但双写策略有个致命问题:两个操作不是原子的,可能成功一个失败一个。更健壮的实现是:

function saveProductSafe($product) {
    $pdo = new PDO('mysql:host=localhost;dbname=shop', 'user', 'pass');
    
    // 开启事务
    $pdo->beginTransaction();
    
    try {
        // 更新数据库
        $stmt = $pdo->prepare("INSERT INTO products (...) VALUES (...)");
        $stmt->execute([...]);
        
        $productId = $pdo->lastInsertId();
        
        // 将缓存更新也纳入事务
        $redis = new Redis();
        $redis->connect('127.0.0.1', 6379);
        
        $cacheKey = "product:" . $productId;
        $redis->multi()
            ->setex($cacheKey, 3600, json_encode($product))
            ->exec();
        
        $pdo->commit();
        return $productId;
    } catch (Exception $e) {
        $pdo->rollBack();
        throw $e;
    }
}

四、最终一致性保障

在分布式系统中,强一致性很难实现,我们通常追求最终一致性。这就像银行转账,可能不会实时到账,但最终一定会一致。

PHP中可以通过以下几种模式实现最终一致性:

  1. 延迟双删策略:
function updateProductWithDelayDelete($id, $data) {
    $pdo = new PDO('mysql:host=localhost;dbname=shop', 'user', 'pass');
    $redis = new Redis();
    $redis->connect('127.0.0.1', 6379);
    
    // 第一次删除缓存
    $redis->del("product:" . $id);
    
    // 更新数据库
    $stmt = $pdo->prepare("UPDATE products SET name=?, price=? WHERE id=?");
    $stmt->execute([$data['name'], $data['price'], $id]);
    
    // 延迟几秒后再次删除
    sleep(2);
    $redis->del("product:" . $id);
    
    return true;
}
  1. 基于binlog的缓存同步:
// 使用MySQL的binlog监听变化
// 需要安装php-mysqlnd扩展
$pdo = new PDO('mysql:host=localhost;dbname=shop', 'user', 'pass', [
    PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4',
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION
]);

// 创建binlog监听
$pdo->exec("SET @master_binlog_checksum = 'NONE'");
$pdo->exec("SET @slave_uuid = UUID()");
$pdo->exec("SET @master_heartbeat_period = 30");

$stmt = $pdo->query("SHOW MASTER STATUS");
$binlogPos = $stmt->fetch(PDO::FETCH_ASSOC);

// 启动binlog监听线程
while (true) {
    $stmt = $pdo->prepare("SHOW BINLOG EVENTS IN ? FROM ? LIMIT 1");
    $stmt->execute([$binlogPos['File'], $binlogPos['Position']]);
    
    $event = $stmt->fetch(PDO::FETCH_ASSOC);
    if ($event) {
        // 解析事件并更新缓存
        processBinlogEvent($event);
        
        // 更新位置
        $binlogPos['Position'] = $event['End_log_pos'];
    }
    
    sleep(1);
}

function processBinlogEvent($event) {
    // 解析binlog事件,更新对应缓存
    if (strpos($event['Info'], 'UPDATE `products`') !== false) {
        preg_match('/WHERE `id`=(\d+)/', $event['Info'], $matches);
        if ($matches[1]) {
            $redis = new Redis();
            $redis->connect('127.0.0.1', 6379);
            $redis->del("product:" . $matches[1]);
        }
    }
}

五、应用场景与技术选型

不同的业务场景需要不同的缓存一致性方案:

  1. 电商商品详情:适合采用主动删除+本地缓存策略
  2. 用户个人信息:适合采用双写+消息队列保证强一致性
  3. 排行榜类数据:适合采用定时刷新策略,允许短暂不一致

技术选型建议:

  • 小型项目:主动删除策略
  • 中型项目:消息队列+缓存更新
  • 大型项目:binlog监听+分布式事务

六、注意事项与最佳实践

  1. 缓存雪崩防护:
// 设置随机过期时间避免集体失效
$expire = 3600 + rand(0, 300); // 1小时±5分钟
$redis->setex($key, $expire, $value);
  1. 缓存穿透处理:
// 使用布隆过滤器或缓存空值
function getProductSafe($id) {
    $product = $redis->get("product:" . $id);
    
    if ($product === false) {
        // 检查布隆过滤器
        if (!$bloomFilter->mightContain($id)) {
            return null;
        }
        
        // 或者缓存空值
        $redis->setex("product:" . $id, 60, 'NULL');
    }
    
    return $product === 'NULL' ? null : $product;
}
  1. 热点Key处理:
// 使用互斥锁防止缓存击穿
function getProductWithLock($id) {
    $lockKey = "lock:product:" . $id;
    $wait = 0;
    
    while (!$redis->set($lockKey, 1, ['NX', 'EX' => 10])) {
        usleep(100000); // 等待100ms
        if (++$wait > 10) {
            throw new Exception("获取资源超时");
        }
    }
    
    try {
        // 临界区代码
        return getProduct($id);
    } finally {
        $redis->del($lockKey);
    }
}

七、总结

缓存一致性是个复杂的问题,没有银弹解决方案。在实际项目中,我们需要根据业务特点选择合适的技术组合。记住几个关键原则:

  1. 缓存不是必须的,只是性能优化手段
  2. 能接受最终一致性的场景不要追求强一致
  3. 复杂方案往往带来新的问题,保持简单
  4. 监控和告警比完美方案更重要

通过合理的缓存更新通知机制、双写策略和最终一致性保障,我们可以在PHP应用中构建高性能且数据可靠的缓存系统。