10分钟解决PhpRedis双写一致性难题:电商库存同步方案
【免费下载链接】phpredis 项目地址: https://gitcode.com/gh_mirrors/php/phpredis
你是否遇到过这样的情况:用户下单成功但库存显示未减少,或者刚上架的商品突然显示售罄?这些问题的根源往往是数据库与缓存(Redis)同步机制出了问题。本文将通过PhpRedis实现三种双写一致性方案,帮你彻底解决数据同步难题。读完你将获得:
- 理解缓存与数据库不一致的3大场景
- 掌握PhpRedis事务与Lua脚本实现原子操作
- 学会分布式锁保障高并发场景下的数据安全
双写一致性问题的本质
在电商系统中,商品库存通常存储在MySQL中,为提升查询性能会将热点数据缓存到Redis。当库存发生变更时,需要同时更新数据库和Redis,这种"双写"操作如果处理不当,就会出现数据不一致。
常见的不一致场景包括:
- 先更缓存后更数据库:缓存更新成功但数据库更新失败
- 先更数据库后更缓存:数据库更新成功但缓存更新失败
- 并发更新:多个请求同时更新导致数据覆盖
PhpRedis作为PHP生态中性能最优的Redis客户端,提供了事务、Lua脚本和分布式锁等机制来解决这些问题。项目核心实现代码位于redis.c和redis_cluster.c,完整API文档可参考docs/Redis.html。
方案一:数据库优先更新策略
这是最简单的实现方式,遵循"先更新数据库,再更新缓存"的原则。当库存发生变更时,首先更新MySQL中的库存数据,成功后再调用PhpRedis的set方法更新缓存。
// 数据库优先更新实现
function updateStock($goodsId, $quantity) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
// 1. 更新数据库
$db = new PDO('mysql:host=localhost;dbname=ecommerce', 'user', 'pass');
$stmt = $db->prepare("UPDATE goods SET stock = stock - :quantity WHERE id = :id");
$stmt->execute([':quantity' => $quantity, ':id' => $goodsId]);
// 2. 更新缓存
if ($stmt->rowCount() > 0) {
$newStock = $db->query("SELECT stock FROM goods WHERE id = $goodsId")->fetchColumn();
$redis->set("stock:$goodsId", $newStock);
return true;
}
return false;
}
这种方案的优点是实现简单,适用于并发量不高的场景。但存在明显缺陷:如果数据库更新成功而缓存更新失败(如网络异常),就会导致缓存数据永久不一致。
方案二:事务回滚机制
PhpRedis提供了事务支持,可以通过multi()和exec()方法将多个Redis命令打包执行,确保要么全部成功,要么全部失败。结合数据库事务,可以实现缓存与数据库的原子更新。
// 事务回滚机制实现
function updateStockWithTransaction($goodsId, $quantity) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
try {
// 1. 开启数据库事务
$db = new PDO('mysql:host=localhost;dbname=ecommerce', 'user', 'pass');
$db->beginTransaction();
// 2. 更新数据库
$stmt = $db->prepare("UPDATE goods SET stock = stock - :quantity WHERE id = :id");
$stmt->execute([':quantity' => $quantity, ':id' => $goodsId]);
if ($stmt->rowCount() == 0) {
throw new Exception("库存不足");
}
// 3. 获取新库存并开启Redis事务
$newStock = $db->query("SELECT stock FROM goods WHERE id = $goodsId")->fetchColumn();
$redis->multi();
$redis->set("stock:$goodsId", $newStock);
// 4. 执行Redis事务并提交数据库事务
$redis->exec();
$db->commit();
return true;
} catch (Exception $e) {
// 5. 发生异常时回滚
$db->rollBack();
return false;
}
}
该方案通过事务机制解决了单节点故障问题,但在分布式系统中,由于数据库和Redis属于不同的事务域,无法实现真正的分布式事务。项目中Redis事务的实现代码位于redis.c#L2345-L2401。
方案三:基于Lua脚本的原子操作
Redis支持Lua脚本,允许将多个命令打包成一个脚本在服务器端原子执行。PhpRedis通过eval()方法支持Lua脚本执行,这是解决双写一致性问题的最优方案之一。
首先创建一个库存更新的Lua脚本文件scripts/update_stock.lua:
-- 检查库存是否充足
local current_stock = redis.call('get', KEYS[1])
if not current_stock or tonumber(current_stock) < tonumber(ARGV[1]) then
return 0
end
-- 扣减库存
redis.call('decrby', KEYS[1], ARGV[1])
return 1
然后在PHP中调用这个Lua脚本:
// 基于Lua脚本的原子操作
function updateStockWithLua($goodsId, $quantity) {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
// 1. 读取Lua脚本
$luaScript = file_get_contents('scripts/update_stock.lua');
// 2. 执行Lua脚本
$result = $redis->eval($luaScript, ["stock:$goodsId", $quantity], 1);
if ($result == 1) {
// 3. Lua脚本执行成功,更新数据库
$db = new PDO('mysql:host=localhost;dbname=ecommerce', 'user', 'pass');
$stmt = $db->prepare("UPDATE goods SET stock = stock - :quantity WHERE id = :id");
$stmt->execute([':quantity' => $quantity, ':id' => $goodsId]);
return true;
}
return false;
}
Lua脚本的优势在于:
- 原子性:脚本中的所有命令作为一个整体执行,不会被其他请求打断
- 减少网络往返:一次请求完成多个操作
- 复杂逻辑实现:支持条件判断和循环等复杂逻辑
PhpRedis对Lua脚本的支持实现位于redis.c#L2510-L2578。
分布式锁保障高并发场景
在分布式系统中,多台服务器同时更新同一商品库存时,需要分布式锁来保证数据一致性。PhpRedis的RedisCluster类提供了分布式环境下的锁实现。
// 分布式锁实现
function updateStockWithLock($goodsId, $quantity) {
$cluster = new RedisCluster(null, [
'127.0.0.1:7000',
'127.0.0.1:7001',
'127.0.0.1:7002'
]);
$lockKey = "lock:stock:$goodsId";
$lockValue = uniqid();
$lockExpire = 5; // 锁过期时间,防止死锁
// 获取分布式锁
$locked = $cluster->set($lockKey, $lockValue, ['NX', 'EX' => $lockExpire]);
if ($locked) {
try {
// 执行库存更新逻辑
$result = updateStockWithLua($goodsId, $quantity);
// 释放锁(使用Lua脚本保证原子性)
$releaseScript = 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end';
$cluster->eval($releaseScript, [$lockKey, $lockValue], 1);
return $result;
} catch (Exception $e) {
// 异常处理
return false;
}
}
// 获取锁失败,说明有其他进程正在更新
return false;
}
RedisCluster的实现代码位于redis_cluster.c,使用前需要先配置Redis集群,具体方法可参考cluster.md。
方案对比与选择建议
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 数据库优先 | 实现简单,兼容性好 | 存在数据不一致风险 | 低并发、非核心业务 |
| 事务回滚 | 保证单节点原子性 | 不支持分布式事务 | 单节点、中低并发 |
| Lua脚本 | 原子性好,减少网络交互 | 需要学习Lua语法 | 高并发、核心业务 |
| 分布式锁 | 解决分布式系统并发问题 | 实现复杂,有性能损耗 | 分布式系统、超高并发 |
对于电商库存等核心业务,建议采用"Lua脚本+分布式锁"的组合方案,既能保证原子性,又能应对分布式环境下的高并发。
监控与运维
为确保双写一致性方案的稳定运行,需要建立完善的监控机制。PhpRedis提供了info()方法获取Redis服务器信息:
// 监控Redis状态
function monitorRedis() {
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$info = $redis->info();
// 记录关键指标
$metrics = [
'used_memory' => $info['used_memory_human'],
'connected_clients' => $info['connected_clients'],
'keyspace_hits' => $info['keyspace_hits'],
'keyspace_misses' => $info['keyspace_misses']
];
// 发送到监控系统
file_put_contents('/var/log/redis/metrics.log', json_encode($metrics) . "\n", FILE_APPEND);
}
关键监控指标包括:
- 缓存命中率(keyspace_hits/(keyspace_hits+keyspace_misses))
- 内存使用率(used_memory/used_memory_max)
- 连接数(connected_clients)
当缓存命中率低于90%或内存使用率超过80%时,需要及时优化。详细的Redis监控指标说明可参考docs/Redis.html#info。
总结与最佳实践
双写一致性问题没有银弹,需要根据业务特点选择合适的方案。在使用PhpRedis实现时,建议遵循以下最佳实践:
- 优先使用Lua脚本减少网络往返和保证原子性
- 高并发场景必须使用分布式锁
- 实现完善的监控告警机制
- 对关键业务采用"缓存+数据库+消息队列"的最终一致性方案
- 定期进行数据一致性校验和修复
PhpRedis项目提供了丰富的测试用例,可参考tests/RedisTest.php了解更多使用示例。通过合理运用本文介绍的方案,你可以构建一个高性能、高一致性的缓存系统,为用户提供更可靠的服务体验。
如果你的系统已经面临严重的一致性问题,建议先使用Redis::scan方法全面扫描缓存数据,与数据库进行比对和修复,再逐步实施本文介绍的方案。
【免费下载链接】phpredis 项目地址: https://gitcode.com/gh_mirrors/php/phpredis
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



