近日遇到了一个分布锁线上问题,导致用户获取锁一直失败,被阻拦提单近2H。 bug原因是Redis setnx获取锁超时,但实际写入成功,出现超时后并没有释放锁。由于锁维度是userId维度,导致用户再次提单一直无法获取锁,提单一直失败,客服同学说:用户情绪很激动,骂骂咧咧的。
为了避免锁超时导致并发问题,系统设置的超时时间过长——2小时。这导致用户2个小时都无法提单。由于系统日订单量在千万,即便超时比例非常低,每天也会有10单以上。用户找客服投诉的意愿十分强烈,并且态度很差。
在解决问题前,先回顾下我们分布式锁的技术实现方案
加锁或解锁异常的情况下,可能出现漏被漏释放。 如果未设置超时时间,那么就会导致锁一直未释放,像我们的提单的场景,如果用户一直无法申请锁,一直提单,用户绝对会有打人的冲动。
if (redis.call('setnx',KEYS[1],ARGV[1]) < 1) then return 0; end; redis.call('EXPIRE',KEYS[1],tonumber(ARGV[2])); return 1;锁误释放问题
假如客户端1持锁时间超过了锁超时时间,redis key被过期删除。然后客户端2申请锁成功。而客户端1 在执行完成后,释放锁。此时会导致 客户端2 申请的锁被客户端1 释放。 如图
Client 1误释放了Client2 申请的锁,导致Client3 可申请锁成功,未起到防并发的目的。由此可见,删除锁的时候不能盲目删除。删除前应该保证是自己申请的锁,才能释放。
如何实现呢?首先setnx 需要保存value。 value中记录了客户端信息,可以使用毫秒级时间戳、UUID或者 机器ip+线程名称等。(毫秒级时间戳足够用了,一般业务场景分布式锁争抢没那么严重)。
删除操作需要实现 Compare AND DELETE (CAD)原子命令。 redis没有提供,需要使用LUA脚本实现
if(redis.call('GET',KEYS[1]) ~= false ) then local v = redis.call('GET', KEYS[1]); if(v~= KEYS[2]) then return -1; end; local res = redis.call('DEL', KEYS[1]); if(res==1) then return 1; else return -2; end; end; return 0;使用redis-cli加载lua脚本:
redis-cli script load "$(cat cad.lua)"执行lua脚本,指定参数参数,传入KEY、Value
1: 值相等,可以删除,且删除成功 0: 当 key1 不存在时 -1: value1 和key1对应的value不相等 -2: value1 和key1对应的value相等,但是DEL执行失败。因为redis lua原子化执行,之前已经判断key1存在,所以这种场景几乎不可能,但预留下Value还有其他更重要的用途
分布式锁为什么会存Value,如果只看到持有锁超时场景,防止误删除是不够的。回到一开始的问题,如果setnx阶段出现了超时,客户端是不清楚锁是否写入成功。需要重试一下,才能确认。(如果不重试就会出现锁漏释放,导致后续一直无法获取锁。)。