一、缓存一致性问题的本质
在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中可以通过以下几种模式实现最终一致性:
- 延迟双删策略:
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;
}
- 基于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]);
}
}
}
五、应用场景与技术选型
不同的业务场景需要不同的缓存一致性方案:
- 电商商品详情:适合采用主动删除+本地缓存策略
- 用户个人信息:适合采用双写+消息队列保证强一致性
- 排行榜类数据:适合采用定时刷新策略,允许短暂不一致
技术选型建议:
- 小型项目:主动删除策略
- 中型项目:消息队列+缓存更新
- 大型项目:binlog监听+分布式事务
六、注意事项与最佳实践
- 缓存雪崩防护:
// 设置随机过期时间避免集体失效
$expire = 3600 + rand(0, 300); // 1小时±5分钟
$redis->setex($key, $expire, $value);
- 缓存穿透处理:
// 使用布隆过滤器或缓存空值
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;
}
- 热点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);
}
}
七、总结
缓存一致性是个复杂的问题,没有银弹解决方案。在实际项目中,我们需要根据业务特点选择合适的技术组合。记住几个关键原则:
- 缓存不是必须的,只是性能优化手段
- 能接受最终一致性的场景不要追求强一致
- 复杂方案往往带来新的问题,保持简单
- 监控和告警比完美方案更重要
通过合理的缓存更新通知机制、双写策略和最终一致性保障,我们可以在PHP应用中构建高性能且数据可靠的缓存系统。
评论