redis分布式锁造成的事故
public SeckillActivityRequestVo seckillHandler(SeckillActivityRequestVo request){
//定义返回对象
SeckillActivityRequestVo response;
//redis的key
String key = "key" + request.getSeckillId;
try{
//获取分布式锁
Boolean lockFlag = redisTemplate.opsForValue().setIfAbsent(key, "val", 10, TimeUnit.SECONDS);
if(lockFlag) {
//HTTP请求用户服务进行相关校验
//用户活动校验
//库存校验
Object stock = redisTemplate.opsForHash().get(key+":info", "stock");
assert stock != null;
if(Integer.parseInt(stock.toString()) <= 0) {
//已经没有库存了,
} else {
redisTemplate.opsForHash().increment(key+":info","stock",-1);
//生成订单信息
//发布订单创建成功是将
//构建相应VO
}
}
} finally {
//释放锁
StringRedisTemplement.delete("key");
}
return response;
}
以上代码,通过分布式锁过期时间有效期10s来保障业务逻辑有足够的执行时间;采用try-finally语句块保证锁一定会及时释放。
事故原因:
稀有商品吸引了大量新用户,其中不乏羊毛党,采用专业的手段刷单。因此用户系统需要提前做好防备:例如接入阿里云人机验证、三要素认证等挡住大量非法用户。正因如此,用户服务一直处于较高的运行负载中。
抢购活动开始的一瞬间,大量的用户校验请求抵达用户服务,导致用户服务网关出现了短暂的相应延迟,有些请求的响应时长超过10s,但由于HTTP请求的响应超时(HTTP响应超时时间30S),导致接口一直阻塞再用户校验那里,10s后,分布式锁已经失效,此时有新的请求进来可以拿到锁,也就是锁被覆盖了。这些阻塞的接口执行完成之后,又会执行释放锁的逻辑,就把其他线程的锁释放了,导致新的请求也可以竞争到锁(恶行循环)。这个时候只能依赖库存校验,但是库存校验又不是原子性的,采用get and compare的方法,就发生了超卖。
事故分析:
-
没有其他系统风险容错处理
由于用户服务吃紧,网关响应延迟,但没有任何对应方式,这是超卖的导火索。
-
看似安全的分布式锁其实一点都不安全
虽然采用了set key value [EX seconds] [PX milliseconds] [NX|XX]的方式,但是如果线程A执行的时间较长没有来得及释放,锁就过期了,此时线程B是可以获取到锁的。当线程A执行完成之后,释放锁,实际上就把线程B的锁释放掉了。这个时候,线程C又是可以获取到锁的,而此时如果线程B执行完释放锁实际上就是释放的线程C设置的锁。这是超卖的直接原因。
-
非原子性的库存校验
非原子性库存校验导致在并发场景下,库存校验结果不准确。这是超卖的根本原因。
解决方案
-
实现相对安全的分布式锁
相对安全的定义:set、del是一一映射的,不会出现把其他线程的锁del的情况。从实际情况的角度来看,即使做到了set、del一一映射,也无法保证业务的绝对安全。因为锁的过期时间始终是有界的,除非不设置过期时间或者把过期时间设置很长,但是这样也会带来其他问题。要想事项相对安全的分布式锁,必须依赖key的value值,在释放锁的时候,通过value值的唯一性来保证不会误删。基于LUA脚本实现原子性的get and compare
public void safeUnLock(String key, String val) { String luaScript = "local in = ARGV[1] local curr = redis.call('get', KEYS[1]) if in == curr then redis.call('del',KEYS[1]) end return 'OK'"; RedisScript<String> redisScript = RedisScript.of(luaScript); redisTemplate.execute(redisScript,Collection.singletonList(ley), Collections.singletonList(value)); }
通过LUA脚本来实现安全地解锁。
-
实现安全地库存校验
如果对并发有深入的了解的话,会发现类似 get and compare / read and save等操作都是非原子性的。如果要实现原子性,我么你可以借助LUA脚本来实现。
//redis会返回操作后的结果,这个过程是原子性的 Long currStory = redisTemolate.opsForHash().increment("key","stock", -1);
改进后的代码
public SeckillActivityRequestVo seckillHandler(SeckillActivityRequestVo request){
//定义返回对象
SeckillActivityRequestVo response;
//redis的key
String key = "key" + request.getSeckillId;
String val = UUID.randomUUID().toString();
try{
//获取分布式锁
Boolean lockFlag = redisTemplate.opsForValue().setIfAbsent(key, val, 10, TimeUnit.SECONDS);
if(lockFlag) {
//HTTP请求用户服务进行相关校验
//用户活动校验
//库存校验
Long currStory = redisTemolate.opsForHash().increment("key","stock", -1);
if(currStory <= 0) {
//已经没有库存了,
} else {
//生成订单信息
//发布订单创建成功是将
//构建相应VO
}
}
} finally {
//释放锁
distributedLocker.safedUnLock(key, val);
}
return response;
}
分布式锁有必要吗
改进之后,其实可以发现,我们借助于redis本身的原子性扣减库存,也是可以保证不会超卖的。对的。但是如果没有这一层锁的话,那么所有请求进来都会走一遍业务逻辑,由于依赖了其他系统,此时就会造成对其他系统的压力增大。这会增加的性能损耗和服务不稳定性,得不偿失。基于分布式锁可以在一定程度上拦截一些流量。
分布式锁的选型
有人提出用RedLock来实现分布式锁。RedLock的可靠性更高,但其代价是牺牲一定的性能。在本场景,这点可靠性的提升远不如性能的提升带来的性价比高。如果对于可靠性极高要求的场景,则可以采用RedLock来实现。