Appearance
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 12. 加锁并扣减脚本
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 13. 库存回滚脚本
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', ...) -- 前面的已执行,无法回滚
end2. 避免长时间阻塞
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 mykey2. 日志输出
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('系统繁忙,请稍后重试');
}⚠️ 注意事项
- 脚本大小: 避免过大的脚本,影响 Redis 性能
- 执行时间: 脚本执行期间 Redis 阻塞,保持简短
- 内存使用: 避免在脚本中创建大量临时变量
- 版本兼容: 注意 Redis 版本对 Lua 特性的支持