Skip to content

Lua 脚本

MineShop 使用 Redis + Lua 脚本实现高并发场景下的原子操作,主要用于库存扣减、分布式锁等场景。

🎯 为什么使用 Lua

原子性保证

普通操作(非原子):
┌─────────┐    ┌─────────┐    ┌─────────┐
│ GET 库存 │ → │ 判断充足 │ → │ DECRBY  │
└─────────┘    └─────────┘    └─────────┘
     ↑              ↑              ↑
     │              │              │
   并发请求可能在任意步骤插入,导致超卖

Lua 脚本(原子):
┌─────────────────────────────────────────┐
│  GET + 判断 + DECRBY 在同一脚本中执行     │
│  Redis 单线程保证原子性                   │
└─────────────────────────────────────────┘

性能优势

  • 减少网络往返次数
  • 单次执行多个命令
  • 避免分布式锁开销

📦 核心脚本

1. 库存扣减脚本

lua
-- deduct_stock.lua
-- KEYS[1] = stock hash key (e.g. product:stock or seckill:stock:123)
-- ARGV = [sku_id_1, quantity_1, sku_id_2, quantity_2, ...]
-- 返回: 1=成功, 0=库存不足

-- 第一轮:检查所有 SKU 库存是否充足
for i = 1, #ARGV, 2 do
    local field = ARGV[i]
    local quantity = tonumber(ARGV[i + 1])
    local current = tonumber(redis.call('HGET', KEYS[1], field) or 0)
    if current < quantity then
        return 0
    end
end

-- 第二轮:全部扣减
for i = 1, #ARGV, 2 do
    local field = ARGV[i]
    local quantity = tonumber(ARGV[i + 1])
    redis.call('HINCRBY', KEYS[1], field, -quantity)
end

return 1

2. 加锁并扣减脚本

lua
-- lock_and_decrement.lua
-- KEYS[1] = lock key
-- KEYS[2] = stock key
-- ARGV[1] = quantity
-- ARGV[2] = lock ttl (seconds)
-- 返回: 1=成功, 0=库存不足, -1=获取锁失败

local lockKey = KEYS[1]
local stockKey = KEYS[2]
local quantity = tonumber(ARGV[1])
local lockTtl = tonumber(ARGV[2])

-- 尝试获取锁
if redis.call("SET", lockKey, "1", "NX", "EX", lockTtl) == false then
    return -1  -- 获取锁失败
end

-- 检查库存
local currentStock = tonumber(redis.call("GET", stockKey) or 0)
if currentStock < quantity then
    redis.call("DEL", lockKey)  -- 释放锁
    return 0  -- 库存不足
end

-- 扣减库存
redis.call("DECRBY", stockKey, quantity)
return 1

3. 库存回滚脚本

lua
-- rollback.lua
-- KEYS[1] = lock key
-- KEYS[2] = stock key
-- ARGV[1] = quantity
-- 返回: 1=成功

local lockKey = KEYS[1]
local stockKey = KEYS[2]
local quantity = tonumber(ARGV[1])

-- 回滚库存
if quantity > 0 then
    redis.call("INCRBY", stockKey, quantity)
end

-- 释放锁
redis.call("DEL", lockKey)
return 1

🔧 PHP 调用方式

使用 eval 执行

php
// DomainOrderStockService.php
private const DEDUCT_SCRIPT = <<<'LUA'
    local stockKey = KEYS[1]
    for i = 1, #ARGV, 2 do
        local field = ARGV[i]
        local quantity = tonumber(ARGV[i + 1])
        local current = tonumber(redis.call('HGET', stockKey, field) or '-1')
        if current < quantity then
            return 0
        end
    end
    for i = 1, #ARGV, 2 do
        local field = ARGV[i]
        local quantity = tonumber(ARGV[i + 1])
        redis.call('HINCRBY', stockKey, field, -quantity)
    end
    return 1
LUA;

public function reserve(array $items, string $stockHashKey): void
{
    $args = [];
    foreach ($items as $skuId => $quantity) {
        $args[] = (string) $skuId;
        $args[] = (string) $quantity;
    }

    // eval(script, keys+args, numkeys)
    $payload = array_merge([$stockHashKey], $args);
    $result = $this->redis()->eval(self::DEDUCT_SCRIPT, $payload, 1);
    
    if ((int) $result !== 1) {
        throw new \RuntimeException('库存不足或商品已下架');
    }
}

使用 evalsha 优化

php
// 预加载脚本获取 SHA1
$sha = $redis->script('load', $script);

// 使用 evalsha 执行(更高效)
$result = $redis->evalsha($sha, $payload, 1);

📊 脚本设计原则

1. 先检查后执行

lua
-- ✅ 正确:先检查所有条件,再执行操作
for i = 1, #ARGV, 2 do
    -- 检查阶段
    if current < quantity then return 0 end
end
for i = 1, #ARGV, 2 do
    -- 执行阶段
    redis.call('HINCRBY', ...)
end

-- ❌ 错误:边检查边执行,可能部分成功
for i = 1, #ARGV, 2 do
    if current < quantity then return 0 end
    redis.call('HINCRBY', ...)  -- 前面的已执行,无法回滚
end

2. 避免长时间阻塞

lua
-- ❌ 避免:大量数据循环
for i = 1, 1000000 do
    redis.call('SET', 'key'..i, 'value')
end

-- ✅ 推荐:分批处理
-- 在 PHP 层面分批调用脚本

3. 返回值设计

lua
-- 使用明确的返回值
return 1   -- 成功
return 0   -- 业务失败(如库存不足)
return -1  -- 系统失败(如获取锁失败)

🔍 调试技巧

1. 本地测试

bash
# 使用 redis-cli 测试脚本
redis-cli EVAL "return redis.call('GET', KEYS[1])" 1 mykey

2. 日志输出

lua
-- 使用 redis.log 输出调试信息
redis.log(redis.LOG_WARNING, "current stock: " .. current)

3. 错误处理

php
try {
    $result = $redis->eval($script, $payload, 1);
} catch (\RedisException $e) {
    // 脚本语法错误或执行异常
    logger()->error('Lua script error', ['error' => $e->getMessage()]);
    throw new \RuntimeException('系统繁忙,请稍后重试');
}

⚠️ 注意事项

  1. 脚本大小: 避免过大的脚本,影响 Redis 性能
  2. 执行时间: 脚本执行期间 Redis 阻塞,保持简短
  3. 内存使用: 避免在脚本中创建大量临时变量
  4. 版本兼容: 注意 Redis 版本对 Lua 特性的支持

📚 相关文档

基于 Apache-2.0 许可发布 | 感谢 MineAdmin 提供的优秀基础框架