1. 当Lua遇见并发:那些年我们踩过的锁坑
在多人协作的厨房里,如果两个厨师同时伸手去拿唯一的煎锅,就会发生尴尬的僵持。类似的场景在Lua并发编程中每天都在上演——当多个线程/协程争抢共享资源时,稍不留神就会陷入死锁的泥潭。特别是在使用Redis等中间件的Lua脚本场景中,由于脚本的原子性特性,锁机制的使用更需要特别讲究。
2. 一个典型的死锁现场还原
让我们通过Redis+Lua的库存扣减案例,看看死锁是如何悄然发生的:
-- Redis Lua脚本示例(问题版本)
local key1 = KEYS[1] -- 库存键
local key2 = KEYS[2] -- 订单键
local qty = ARGV[1] -- 购买数量
-- 第一把锁(库存锁)
local stock_lock = redis.call('SETNX', 'lock:'..key1, '1')
if stock_lock == 0 then
return {err = 'STOCK_LOCKED'}
end
-- 第二把锁(订单锁)
local order_lock = redis.call('SETNX', 'lock:'..key2, '1')
if order_lock == 0 then
redis.call('DEL', 'lock:'..key1) -- 释放库存锁
return {err = 'ORDER_LOCKED'}
end
-- 执行业务逻辑
local stock = redis.call('GET', key1)
if stock >= qty then
redis.call('DECRBY', key1, qty)
redis.call('HSET', key2, 'status', 'created')
end
-- 释放锁(问题所在)
redis.call('DEL', 'lock:'..key1)
redis.call('DEL', 'lock:'..key2)
return {ok = 'SUCCESS'}
这个看似正常的脚本其实暗藏杀机。当两个请求同时执行时:
- 请求A先获得库存锁,请求B先获得订单锁
- 请求A在等待订单锁,请求B在等待库存锁
- 两个请求互相等待对方释放锁,形成经典死锁
3. 四大求生法则:破解死锁的密码
3.1 统一加锁顺序
-- 修复版:强制统一加锁顺序
local keys_sorted = {KEYS[1], KEYS[2]}
table.sort(keys_sorted) -- 对键名进行排序
-- 按排序后的顺序加锁
for _, key in ipairs(keys_sorted) do
local lock = redis.call('SETNX', 'lock:'..key, '1')
if lock == 0 then
-- 释放已获得的锁
for _, k in ipairs(keys_sorted) do
if k == key then break end
redis.call('DEL', 'lock:'..k)
end
return {err = 'LOCK_FAILED'}
end
end
通过强制排序锁资源,确保所有请求都按相同顺序获取锁,就像交通规则要求所有车辆靠右行驶一样,从根本上避免了循环等待。
3.2 给锁加上"保质期"
-- 改进版:带超时的锁
local function acquire_lock(key)
local lock_key = 'lock:'..key
local timeout = 5 -- 单位:秒
return redis.call('SET', lock_key, '1', 'NX', 'EX', timeout)
end
-- 使用示例
if not acquire_lock(KEYS[1]) then
return {err = 'LOCK_TIMEOUT'}
end
通过EX
参数设置自动过期时间,相当于给锁安装了一个定时炸弹。即使持有锁的进程崩溃,锁也会自动释放,避免成为"僵尸锁"。
3.3 锁的版本控制
-- 优化版:带版本号的锁
local lock_version = redis.call('INCR', 'lock_version') -- 生成唯一版本号
-- 获取锁时存储版本号
if redis.call('SET', 'lock:stock', lock_version, 'NX', 'EX', 10) then
-- 执行业务逻辑...
-- 释放时校验版本
if redis.call('GET', 'lock:stock') == lock_version then
redis.call('DEL', 'lock:stock')
end
end
这种机制就像超市的寄存柜,取物品时需要核对小票。防止误删其他请求获取的锁,特别是在锁超时后业务仍在执行的场景。
3.4 有限重试策略
local max_retries = 3
local retry_delay = 0.5 -- 单位:秒
for i=1,max_retries do
local lock1 = acquire_lock('resource1')
if lock1 then
local lock2 = acquire_lock('resource2')
if lock2 then
-- 成功获取所有锁
break
else
release_lock('resource1')
end
end
if i < max_retries then
-- 指数退避等更智能的等待策略
redis.call('SLEEP', retry_delay * i)
end
end
类似于打电话时的"重拨间隔",避免无限重试导致雪崩效应。建议配合随机退避算法,增加系统稳定性。
4. 锁机制的应用场景与注意事项
适用场景
- 分布式库存管理
- 订单状态变更
- 全局配置更新
- 限流计数器操作
技术特点对比
方案 | 优点 | 缺点 |
---|---|---|
简单锁 | 实现简单、性能好 | 易死锁、无超时机制 |
超时锁 | 避免僵尸锁 | 存在误删风险 |
版本锁 | 安全可靠 | 实现复杂度较高 |
重试机制 | 提高成功率 | 可能增加系统负载 |
避坑指南
- 锁的粒度要适中:太粗影响并发,太细增加复杂度
- 避免在锁内进行网络请求(可能阻塞时间过长)
- 嵌套锁是危险操作,必须严格排序
- 监控锁的等待时间和获取次数
- 在Redis集群环境下注意hash tag的使用
5. 总结与展望
在Lua的并发世界里,锁就像交通信号灯——用好了秩序井然,用错了就是灾难现场。通过本文的四个锦囊:统一顺序、设置超时、版本控制、合理重试,我们能在大部分场景下避免死锁问题。但也要记住,锁不是万能的解药,在某些场景下可以考虑使用CAS(Compare-And-Swap)等无锁方案。
未来的Lua生态中,随着协程机制的普及和更智能的锁管理工具出现,相信我们能找到更优雅的并发解决方案。但在此之前,掌握这些基础防御技能,足以让我们在并发编程的战场上多几分胜算。