各位朋友,大家好!
我是你们的老朋友,一个在代码泥潭里摸爬滚打多年的资深程序员。今天咱们不聊那些虚头巴脑的架构图,也不讲那些深奥得让人头秃的微服务理论。咱们来聊点实实在在的、能救命的东西。
我想问问在座的各位,有没有过这种经历:你的PHP代码写得像诗一样优雅,逻辑严密,一旦部署到服务器上,高并发一来,就像一群饿狼冲进了自助餐厅,结果呢?库存扣减出错、库存变成负数、用户抢到了东西却没库存。那一瞬间,你的数据库在哭泣,你的业务在报错,你的老板在咆哮。
这不仅仅是“代码写错了”,这叫竞态条件。
在这个分布式世界里,PHP虽然是单线程的,但你的应用服务器是多进程的。当一个请求进来了,PHP说:“我先检查一下库存”,还没等它去数据库改库存,另一个请求也进来了,也说:“我先检查一下库存”。于是,两个家伙都觉得自己库存够,然后一起扣减。
这就是“非原子性”的灾难。
今天,我们要请出一位助教——Redis Lua脚本。它就像是给Redis装上了核动力,让你的复杂业务操作瞬间拥有了“原子性”。
好,搬好小板凳,咱们开始这场关于“原子性舞蹈”的深度讲座。
第一部分:当PHP遇上Redis,为什么我们需要“胶水”?
首先,我们要搞清楚一个概念。Redis官方文档里老祖宗早就给过忠告了:不要在Redis里写复杂的循环,也不要写耗时的运算。
但是,现实业务往往不是简单的 GET 和 SET。比如:我要扣减库存,同时要记录日志,还要检查用户的钱包余额,最后还要把订单写入数据库。
如果用PHP来写:
- PHP查Redis,库存还有5个。
- PHP查数据库,用户余额10元。
- PHP写数据库,扣减库存为4。
- PHP写数据库,扣减余额为9。
- PHP写数据库,记录日志。
这时候,如果第3步卡住了(网络抖动、数据库锁死),或者第4步因为某种原因回滚了,第5步却提交了。这就导致了Redis里库存是4,但数据库里没扣。数据不一致。
这就像你在这个房间里跟女朋友(PHP)吵架,你刚转身把门锁上(扣减库存),突然想起钥匙没带(数据库操作失败),结果女朋友还在外面等着呢。
而Lua脚本是什么?Lua脚本就是“一次发送,原子执行”。你把这一大串逻辑打包成一个字符串扔给Redis,Redis会把它当成一个命令执行。执行完之前,谁也别想插队。
第二部分:Hello World——最简单的原子操作
在讲复杂的业务之前,咱们先来个热身。假设我们要做一个简单的计数器。
场景: 网站访问量统计。
传统PHP方式(非原子,有风险)
// PHP代码
$count = $redis->get('visit_count');
$count++;
$redis->set('visit_count', $count);
这代码看着没问题吧?但在高并发下,这就是个定时炸弹。如果并发有1000,两个请求可能都读到 1000,然后都变成 1001,实际访问量却只增加了1。
Redis Lua脚本方式(原子,稳妥)
// Lua脚本代码
-- KEYS[1] 是键名
-- ARGV[1] 是步长(增加量)
return redis.call('INCRBY', KEYS[1], ARGV[1])
// PHP调用代码
$script = "
return redis.call('INCRBY', KEYS[1], ARGV[1])
";
$result = $redis->eval($script, ['visit_count'], 1);
哪怕并发是10000,Redis也能保证这行Lua脚本被执行10000次,且结果绝对是正确的。
第三部分:实战——秒杀场景下的库存扣减(核心干货)
好了,热身结束。现在我们进入重头戏:高并发秒杀库存扣减。
这是目前最经典、最容易出问题的场景。
需求分析:
- Redis里有库存
stock,初始为 100。 - 用户请求来了,想要买1个。
- 如果库存 >= 1,则扣减,返回成功。
- 如果库存 < 1,则扣减,返回失败。
- 最好能返回扣减后的剩余库存。
方案A:先查后减(PHP逻辑,不可靠)
if ($redis->get('stock') > 0) {
$redis->decr('stock');
// ... 然后去写数据库 ...
}
老兄,这有什么用?在PHP的判断和Redis的decr之间,又会有新的请求冲进来。这就像买彩票,你要先去摇号再决定买不买,中间有无数次摇号机会。
方案B:Lua脚本实现(稳了)
我们要把“判断”和“扣减”这两个动作,通过Lua脚本“粘”在一起。
-- 库存脚本
-- KEYS[1]: 库存key
-- ARGV[1]: 购买数量
if tonumber(redis.call('get', KEYS[1])) >= tonumber(ARGV[1]) then
return redis.call('decrby', KEYS[1], ARGV[1])
else
return 0
end
PHP端的实现:
$luaScript = <<<'EOT'
if tonumber(redis.call('get', KEYS[1])) >= tonumber(ARGV[1]) then
return redis.call('decrby', KEYS[1], ARGV[1])
else
return 0
end
EOT;
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
// 1. 库存key
$stockKey = 'seckill:stock:1001';
// 2. 调用脚本
// eval(脚本内容, 参数数组, key的数量)
// 注意:第二个参数如果是数组,要索引0开始;如果是字符串,直接传值。
// 这里的ARGV[1]是购买数量1。
$result = $redis->eval($luaScript, [$stockKey], 1);
if ($result > 0) {
echo "恭喜!抢购成功,剩余库存:{$result}";
// TODO: 此时再去操作数据库写入订单
} else {
echo "抢购失败,库存不足";
}
为什么这个稳?
因为Redis在执行这段Lua脚本时,是单线程顺序执行的。
时刻 T1:请求A进来,拿到库存100,判断100>=1,执行decrby,库存变成99。Redis引擎准备执行下一个命令。
时刻 T2:请求B进来,拿到库存99,判断99>=1,执行decrby,库存变成98。
在这个时间段内,没有人能插队。
第四部分:进阶——分布式锁与Lua的完美结合
光有库存扣减还不够。在复杂的分布式系统中,我们经常需要加锁。
比如,防止别人重复提交订单。或者防止两个服务器同时往数据库里写数据。
Redis有个命令叫 SETNX (Set if Not eXists)。但这东西很坑爹,因为它没有过期时间。如果不设置过期时间,你的锁就永远不会释放(除非Redis重启)。如果设置了过期时间,万一脚本执行慢了,锁过期了,别人把锁抢走了,你的脚本执行完了却还以为自己持有锁,这更坑。
所以,Lua脚本天生适合用来写“健壮的锁”。
需求:获取锁
- 锁的Key:
lock:user_id:order_id - 锁的Value:
random_string(用于判断是不是自己加的锁) - 过期时间:30秒
Lua脚本逻辑:
- 如果Key不存在,设置Key为Value,并设置过期时间。
- 如果Key存在,返回0(获取失败)。
-- 获取分布式锁脚本
-- KEYS[1]: 锁的key
-- ARGV[1]: 锁的value (UUID)
-- ARGV[2]: 过期时间(秒)
if redis.call("setnx", KEYS[1], ARGV[1]) == 1 then
redis.call("pexpire", KEYS[1], ARGV[2])
return 1
else
return 0
end
PHP调用:
$lockScript = <<<'EOT'
if redis.call("setnx", KEYS[1], ARGV[1]) == 1 then
redis.call("pexpire", KEYS[1], ARGV[2])
return 1
else
return 0
end
EOT;
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$lockKey = 'lock:order:1024';
$lockValue = uniqid(); // 生成个唯一ID
$expireTime = 10; // 10秒自动过期
$isLocked = $redis->eval($lockScript, [$lockKey, $lockValue, $expireTime], 1);
if ($isLocked) {
echo "恭喜你,拿到了锁!开始处理业务...n";
// 执行业务逻辑,比如生成订单...
// 业务处理完毕
echo "业务处理结束,释放锁。n";
// 释放锁的Lua脚本
$releaseScript = <<<'EOT'
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
EOT;
// 释放锁,一定要带上value,防止误删别人的锁
$redis->eval($releaseScript, [$lockKey, $lockValue], 1);
} else {
echo "哎呀,锁被别人抢走了,你只能干瞪眼了。";
}
这里有一个超级大坑:
在释放锁的时候,为什么我们要判断 get == value?
因为如果不用Lua,PHP代码是:
if ($redis->get($key) == $value) { $redis->del($key); }
如果当前请求拿到了锁,去处理业务。此时,锁的过期时间到了,Redis自动删除了锁。然后另一个请求B拿走了锁,也去处理业务。
这时,请求A处理完了,执行 $redis->del($key)。它成功删除了请求B的锁!
这下好了,请求B在执行业务的时候,手里没有任何锁的保护了(因为它以为锁还在)。这就叫“误删锁”。
所以,释放锁这个动作,也必须是原子的!
第五部分:更复杂的业务——预减库存 + 异步落库
在实际的电商大促中,Redis Lua通常只是前奏。真正的“大戏”是在Redis扣减成功后,把订单扔给MQ(消息队列),异步写入数据库。
这需要跨多个Key的操作。
场景:
- 减库存:
stock:1001(当前99) - 生成订单:
order:uid:123(状态:待支付) - 积分奖励:
user:10086:score(加10分)
这三个操作必须要么全做,要么全不做。
Lua脚本编写
local stockKey = KEYS[1] -- stock:1001
local orderKey = KEYS[2] -- order:10086:123
local scoreKey = KEYS[3] -- user:10086:score
local buyNum = tonumber(ARGV[1])
local orderId = ARGV[2]
local scoreAdd = tonumber(ARGV[3])
-- 1. 检查库存
local stock = tonumber(redis.call('get', stockKey))
if stock < buyNum then
return -1 -- 库存不足
end
-- 2. 扣减库存
redis.call('decrby', stockKey, buyNum)
-- 3. 创建订单
redis.call('hmset', orderKey, 'status', 'pending', 'create_time', os.time())
-- 4. 增加积分
redis.call('incrby', scoreKey, scoreAdd)
-- 5. 返回成功信息
return 1
这个脚本演示了Redis Lua强大的地方:它不仅能做数值计算,还能做Hash操作、List操作、Set操作。而且,这些操作在脚本执行期间是串行化的。
哪怕你的业务逻辑涉及到三个不同的数据结构,Lua也能保证它们之间的顺序一致性。
第六部分:PHP开发者的最佳实践(避坑指南)
讲了这么多,咱们来聊聊如何在PHP中优雅地使用Lua。别为了用Lua而用Lua,那样会变成一个“脚本小子”。
1. 脚本缓存(SCRIPT LOAD)
如果你每次请求都发送一大段Lua代码字符串给Redis,那带宽和网络开销会大得吓人。Redis有一个脚本缓存机制。
做法:
- 在PHP启动时,或者在配置中心,把Lua脚本Hash算出来。
- 后续调用直接用
evalsha(脚本哈希)。 - 如果脚本变了,重新加载。
代码示例:
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
// 定义脚本
$script = "return redis.call('get', KEYS[1])";
// 1. 首次加载,返回SHA1
$sha1 = $redis->script('load', $script);
// 2. 后续使用 evalsha
$redis->evalsha($sha1, ['mykey'], 1);
记住: 生产环境中,强烈建议使用 evalsha。
2. 不要在Lua里写死码
不要把逻辑硬编码在PHP字符串里,特别是大段的业务逻辑。这会导致代码维护极其困难。你应该把Lua脚本放在配置文件里,或者专门的脚本管理后台里,然后通过Key去加载。
3. 返回值处理
Lua脚本执行完毕,Redis会返回一个值给PHP。这个值可以是数字、字符串、Hash数组(如果脚本里用了 redis.call 的返回值拼接)。
但是,注意:如果你的Lua脚本里执行了多条命令,Redis默认只返回最后一条命令的返回值。
比如:
redis.call('set', 'a', 1)
redis.call('set', 'b', 2)
return 3
PHP这边只能收到 3。
如果你需要拿到所有操作的结果,需要在Lua里手动拼接:
local r1 = redis.call('set', 'a', 1)
local r2 = redis.call('set', 'b', 2)
return {r1, r2, 3} -- 返回数组
PHP接收后是一个数组。
4. 警惕阻塞(Long-Running Scripts)
Redis是单线程的。如果你在Lua脚本里写了一个 while(true) 循环,或者调用了一个非常耗时的Redis命令(比如 SCAN 遍历几百万个Key),你的整个Redis实例都会卡住。
原则: Lua脚本执行时间最好控制在 1毫秒到 10毫秒之间。如果超过了,说明你的业务逻辑太重了,该考虑拆分了。
第七部分:调试技巧
写Lua脚本最痛苦的是什么?是报错!
PHP里抛个异常你知道在哪,Redis Lua里报错,你有时候连行号都找不到,因为Redis不会告诉你具体哪一行挂了。
技巧:使用 redis-cli 调试
别老是用PHP去Debug。先在本地装个redis-cli。
- 把你的Lua脚本内容复制出来。
- 用
redis-cli --eval运行。
redis-cli --eval my_script.lua, key1 key2, arg1 arg2
如果你搞错了KEYS的个数或者ARGV,或者语法错了,redis-cli会直接抛出错误信息,告诉你具体哪一行代码出问题了。这比在PHP里用 try-catch 捕获错误方便多了。
第八部分:终极思考——原子性的边界
最后,我想和大家探讨一个更深层次的问题。
Redis Lua脚本保证了Redis内部操作的原子性。也就是我上面说的库存扣减、锁获取,这些都是100%原子且正确的。
但是,它不保证整个分布式系统的一致性。
举个极端的例子:
- Lua脚本在Redis里成功扣减了库存(库存-1)。
- Lua脚本执行完毕,返回成功给PHP。
- PHP准备发送MQ消息写入订单。
- 此时服务器断电了。MQ没发出去,数据库也没写。
- Redis里库存是-1,但数据库里没有订单。
这就是“业务上的不一致”。
Redis Lua是“局部的一致性保证者”,它帮你挡住了大部分因为并发导致的脏数据。但是,数据的最终一致性,还需要配合消息队列、定时任务、补偿机制来兜底。
所以,不要迷信Lua脚本。它不是万能药,它只是一个性能极高的胶水,帮你把Redis里的原子操作粘得更紧密。
总结(不,我们没有总结,我们才刚刚开始)
好了,今天的讲座差不多就到这儿了。
我们聊了什么?
- 为什么PHP直接操作Redis会有竞态条件。
- Redis Lua脚本是如何通过单线程机制实现原子性的。
- 我们通过秒杀扣减、分布式锁、跨键操作这三个经典场景,演示了Lua的实际威力。
- 我们还讨论了生产环境中的脚本缓存、错误处理和性能陷阱。
记住,代码不仅仅是写给机器看的,更是写给未来维护它的人看的。用Lua脚本能写出更健壮、更优雅的业务逻辑,这是每个资深PHP工程师的必修课。
如果你学会了这个技巧,下次面试官问你:“如何实现高并发下的库存扣减?”你就可以笑着拍拍胸脯,拿出这段代码,说:“看,这就是原子性的力量。”
祝大家的代码永远没有Bug,库存永远扣不完!
散会!记得给Redis点个赞!