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. 锁机制的应用场景与注意事项

适用场景

  • 分布式库存管理
  • 订单状态变更
  • 全局配置更新
  • 限流计数器操作

技术特点对比

方案 优点 缺点
简单锁 实现简单、性能好 易死锁、无超时机制
超时锁 避免僵尸锁 存在误删风险
版本锁 安全可靠 实现复杂度较高
重试机制 提高成功率 可能增加系统负载

避坑指南

  1. 锁的粒度要适中:太粗影响并发,太细增加复杂度
  2. 避免在锁内进行网络请求(可能阻塞时间过长)
  3. 嵌套锁是危险操作,必须严格排序
  4. 监控锁的等待时间和获取次数
  5. 在Redis集群环境下注意hash tag的使用

5. 总结与展望

在Lua的并发世界里,锁就像交通信号灯——用好了秩序井然,用错了就是灾难现场。通过本文的四个锦囊:统一顺序、设置超时、版本控制、合理重试,我们能在大部分场景下避免死锁问题。但也要记住,锁不是万能的解药,在某些场景下可以考虑使用CAS(Compare-And-Swap)等无锁方案。

未来的Lua生态中,随着协程机制的普及和更智能的锁管理工具出现,相信我们能找到更优雅的并发解决方案。但在此之前,掌握这些基础防御技能,足以让我们在并发编程的战场上多几分胜算。