PHP如何利用Redis Lua脚本实现原子性复杂业务操作

各位朋友,大家好!

我是你们的老朋友,一个在代码泥潭里摸爬滚打多年的资深程序员。今天咱们不聊那些虚头巴脑的架构图,也不讲那些深奥得让人头秃的微服务理论。咱们来聊点实实在在的、能救命的东西。

我想问问在座的各位,有没有过这种经历:你的PHP代码写得像诗一样优雅,逻辑严密,一旦部署到服务器上,高并发一来,就像一群饿狼冲进了自助餐厅,结果呢?库存扣减出错、库存变成负数、用户抢到了东西却没库存。那一瞬间,你的数据库在哭泣,你的业务在报错,你的老板在咆哮。

这不仅仅是“代码写错了”,这叫竞态条件

在这个分布式世界里,PHP虽然是单线程的,但你的应用服务器是多进程的。当一个请求进来了,PHP说:“我先检查一下库存”,还没等它去数据库改库存,另一个请求也进来了,也说:“我先检查一下库存”。于是,两个家伙都觉得自己库存够,然后一起扣减。

这就是“非原子性”的灾难。

今天,我们要请出一位助教——Redis Lua脚本。它就像是给Redis装上了核动力,让你的复杂业务操作瞬间拥有了“原子性”。

好,搬好小板凳,咱们开始这场关于“原子性舞蹈”的深度讲座。


第一部分:当PHP遇上Redis,为什么我们需要“胶水”?

首先,我们要搞清楚一个概念。Redis官方文档里老祖宗早就给过忠告了:不要在Redis里写复杂的循环,也不要写耗时的运算。

但是,现实业务往往不是简单的 GETSET。比如:我要扣减库存,同时要记录日志,还要检查用户的钱包余额,最后还要把订单写入数据库。

如果用PHP来写:

  1. PHP查Redis,库存还有5个。
  2. PHP查数据库,用户余额10元。
  3. PHP写数据库,扣减库存为4。
  4. PHP写数据库,扣减余额为9。
  5. 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次,且结果绝对是正确的。


第三部分:实战——秒杀场景下的库存扣减(核心干货)

好了,热身结束。现在我们进入重头戏:高并发秒杀库存扣减

这是目前最经典、最容易出问题的场景。

需求分析:

  1. Redis里有库存 stock,初始为 100。
  2. 用户请求来了,想要买1个。
  3. 如果库存 >= 1,则扣减,返回成功。
  4. 如果库存 < 1,则扣减,返回失败。
  5. 最好能返回扣减后的剩余库存。

方案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脚本逻辑:

  1. 如果Key不存在,设置Key为Value,并设置过期时间。
  2. 如果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的操作。

场景:

  1. 减库存:stock:1001 (当前99)
  2. 生成订单:order:uid:123 (状态:待支付)
  3. 积分奖励: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有一个脚本缓存机制。

做法:

  1. 在PHP启动时,或者在配置中心,把Lua脚本Hash算出来。
  2. 后续调用直接用 evalsha(脚本哈希)。
  3. 如果脚本变了,重新加载。

代码示例:

$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。

  1. 把你的Lua脚本内容复制出来。
  2. redis-cli --eval 运行。
redis-cli --eval my_script.lua, key1 key2, arg1 arg2

如果你搞错了KEYS的个数或者ARGV,或者语法错了,redis-cli会直接抛出错误信息,告诉你具体哪一行代码出问题了。这比在PHP里用 try-catch 捕获错误方便多了。


第八部分:终极思考——原子性的边界

最后,我想和大家探讨一个更深层次的问题。

Redis Lua脚本保证了Redis内部操作的原子性。也就是我上面说的库存扣减、锁获取,这些都是100%原子且正确的。

但是,它不保证整个分布式系统的一致性。

举个极端的例子:

  1. Lua脚本在Redis里成功扣减了库存(库存-1)。
  2. Lua脚本执行完毕,返回成功给PHP。
  3. PHP准备发送MQ消息写入订单。
  4. 此时服务器断电了。MQ没发出去,数据库也没写。
  5. Redis里库存是-1,但数据库里没有订单。

这就是“业务上的不一致”。

Redis Lua是“局部的一致性保证者”,它帮你挡住了大部分因为并发导致的脏数据。但是,数据的最终一致性,还需要配合消息队列定时任务补偿机制来兜底。

所以,不要迷信Lua脚本。它不是万能药,它只是一个性能极高的胶水,帮你把Redis里的原子操作粘得更紧密。


总结(不,我们没有总结,我们才刚刚开始)

好了,今天的讲座差不多就到这儿了。

我们聊了什么?

  1. 为什么PHP直接操作Redis会有竞态条件。
  2. Redis Lua脚本是如何通过单线程机制实现原子性的。
  3. 我们通过秒杀扣减、分布式锁、跨键操作这三个经典场景,演示了Lua的实际威力。
  4. 我们还讨论了生产环境中的脚本缓存、错误处理和性能陷阱。

记住,代码不仅仅是写给机器看的,更是写给未来维护它的人看的。用Lua脚本能写出更健壮、更优雅的业务逻辑,这是每个资深PHP工程师的必修课。

如果你学会了这个技巧,下次面试官问你:“如何实现高并发下的库存扣减?”你就可以笑着拍拍胸脯,拿出这段代码,说:“看,这就是原子性的力量。”

祝大家的代码永远没有Bug,库存永远扣不完!

散会!记得给Redis点个赞!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注