基于Redis使用分布式锁在当今已经不是什么新鲜事了。本篇文章主要是基于咱们实际项目中由于redis分布式锁形成的事故分析及解决方案。
背景:咱们项目中的抢购订单采用的是分布式锁来解决的。有一次,运营作了一个飞天茅台的抢购活动,库存100瓶,可是却超卖了!要知道,这个地球上飞天茅台的稀缺性啊!!!事故定为P0级重大事故...只能坦然接受。整个项目组被扣绩效了~~事故发生后,CTO指名点姓让我带头冲锋来处理,好吧,冲~java
通过一番了解后,得知这个抢购活动接口之前历来没有出现过这种状况,可是此次为何会超卖呢?缘由在于:以前的抢购商品都不是什么稀缺性商品,而此次活动竟然是飞天茅台,经过埋点数据分析,各项数据基本都是成倍增加,活动热烈程度可想而知!话很少说,直接上核心代码,机密部分作了伪代码处理。。。redis
public SeckillActivityRequestVO seckillHandle(SeckillActivityRequestVO request) {
SeckillActivityRequestVO response;
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 {
// 释放锁
stringRedisTemplate.delete("key");
// 构建响应VO
}
return response;
}
复制代码
以上代码,经过分布式锁过时时间有效期10s来保障业务逻辑有足够的执行时间;采用try-finally语句块保证锁必定会及时释放。业务代码内部也对库存进行了校验。看起来很安全啊~ 别急,继续分析。。。算法
飞天茅台抢购活动吸引了大量新用户下载注册咱们的APP,其中,不乏不少羊毛党,采用专业的手段来注册新用户来薅羊毛和刷单。固然咱们的用户系统提早作好了防备,接入阿里云人机验证、三要素认证以及自研的风控系统等各类十八般武艺,挡住了大量的非法用户。此处不由点个赞~
但也正因如此,让用户服务一直处于较高的运行负载中。
抢购活动开始的一瞬间,大量的用户校验请求打到了用户服务。致使用户服务网关出现了短暂的响应延迟,有些请求的响应时长超过了10s,但因为HTTP请求的响应超时咱们设置的是30s,这就致使接口一直阻塞在用户校验那里,10s后,分布式锁已经失效了,此时有新的请求进来是能够拿到锁的,也就是说锁被覆盖了。这些阻塞的接口执行完以后,又会执行释放锁的逻辑,这就把其余线程的锁释放了,致使新的请求也能够竞争到锁~这真是一个极其恶劣的循环。
这个时候只能依赖库存校验,可是恰恰库存校验不是非原子性的,采用的是get and compare 的方式,超卖的悲剧就这样发生了~~~缓存
仔细分析下来,能够发现,这个抢购接口在高并发场景下,是有严重的安全隐患的,主要集中在三个地方:安全
经过以上分析,问题的根本缘由在于库存校验严重依赖了分布式锁。由于在分布式锁正常set、del的状况下,库存校验是没有问题的。可是,当分布式锁不安全可靠的时候,库存校验就没有用了。服务器
知道了缘由以后,咱们就能够对症下药了。并发
相对安全的定义:set、del是一一映射的,不会出现把其余现成的锁del的状况。从实际状况的角度来看,即便能作到set、del一一映射,也没法保障业务的绝对安全。由于锁的过时时间始终是有界的,除非不设置过时时间或者把过时时间设置的很长,但这样作也会带来其余问题。故没有意义。
要想实现相对安全的分布式锁,必须依赖key的value值。在释放锁的时候,经过value值的惟一性来保证不会勿删。咱们基于LUA脚本实现原子性的get and compare,以下:dom
public void safedUnLock(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, Collections.singletonList(key), Collections.singleton(val));
}
复制代码
咱们经过LUA脚原本实现安全地解锁。分布式
若是咱们对于并发有比较深刻的了解的话,会发现想 get and compare/ read and save 等操做,都是非原子性的。若是要实现原子性,咱们也能够借助LUA脚原本实现。但就咱们这个例子中,因为抢购活动一单只能下1瓶,所以能够不用基于LUA脚本实现而是基于redis自己的原子性。缘由在于:高并发
// redis会返回操做以后的结果,这个过程是原子性的
Long currStock = redisTemplate.opsForHash().increment("key", "stock", -1);
复制代码
发现没有,代码中的库存校验彻底是“多此一举”。
通过以上的分析以后,咱们决定新建一个DistributedLocker类专门用于处理分布式锁。
public SeckillActivityRequestVO seckillHandle(SeckillActivityRequestVO request) {
SeckillActivityRequestVO response;
String key = "key:" + request.getSeckillId();
String val = UUID.randomUUID().toString();
try {
Boolean lockFlag = distributedLocker.lock(key, val, 10, TimeUnit.SECONDS);
if (!lockFlag) {
// 业务异常
}
// 用户活动校验
// 库存校验,基于redis自己的原子性来保证
Long currStock = stringRedisTemplate.opsForHash().increment(key + ":info", "stock", -1);
if (currStock < 0) { // 说明库存已经扣减完了。
// 业务异常。
log.error("[抢购下单] 无库存");
} else {
// 生成订单
// 发布订单建立成功事件
// 构建响应
}
} finally {
distributedLocker.safedUnLock(key, val);
// 构建响应
}
return response;
}
复制代码
改进以后,其实能够发现,咱们借助于redis自己的原子性扣减库存,也是能够保证不会超卖的。对的。可是若是没有这一层锁的话,那么全部请求进来都会走一遍业务逻辑,因为依赖了其余系统,此时就会形成对其余系统的压力增大。这会增长的性能损耗和服务不稳定性,得不偿失。基于分布式锁能够在必定程度上拦截一些流量。
有人提出用RedLock来实现分布式锁。RedLock的可靠性更高,但其代价是牺牲必定的性能。在本场景,这点可靠性的提高远不如性能的提高带来的性价比高。若是对于可靠性极高要求的场景,则能够采用RedLock来实现。
因为bug须要紧急修复上线,所以咱们将其优化并在测试环境进行了压测以后,就立马热部署上线了。实际证实,这个优化是成功的,性能方面略微提高了一些,并在分布式锁失效的状况下,没有出现超卖的状况。
然而,还有没有优化空间呢?有的!
因为服务是集群部署,咱们能够将库存均摊到集群中的每一个服务器上,经过广播通知到集群的各个服务器。网关层基于用户ID作hash算法来决定请求到哪一台服务器。这样就能够基于应用缓存来实现库存的扣减和判断。性能又进一步提高了!
// 经过消息提早初始化好,借助ConcurrentHashMap实现高效线程安全
private static ConcurrentHashMap<Long, Boolean> SECKILL_FLAG_MAP = new ConcurrentHashMap<>();
// 经过消息提早设置好。因为AtomicInteger自己具有原子性,所以这里能够直接使用HashMap
private static Map<Long, AtomicInteger> SECKILL_STOCK_MAP = new HashMap<>();
...
public SeckillActivityRequestVO seckillHandle(SeckillActivityRequestVO request) {
SeckillActivityRequestVO response;
Long seckillId = request.getSeckillId();
if(!SECKILL_FLAG_MAP.get(requestseckillId)) {
// 业务异常
}
// 用户活动校验
// 库存校验
if(SECKILL_STOCK_MAP.get(seckillId).decrementAndGet() < 0) {
SECKILL_FLAG_MAP.put(seckillId, false);
// 业务异常
}
// 生成订单
// 发布订单建立成功事件
// 构建响应
return response;
}
复制代码
经过以上的改造,咱们就彻底不须要依赖redis了。性能和安全性两方面都能进一步获得提高!
固然,此方案没有考虑到机器的动态扩容、缩容等复杂场景,若是还要考虑这些话,则不如直接考虑分布式锁的解决方案。
稀缺商品超卖绝对是重大事故。若是超卖数量多的话,甚至会给平台带来很是严重的经营影响和社会影响。通过本次事故,让我意识到对于项目中的任何一行代码都不能掉以轻心,不然在某些场景下,这些正常工做的代码就会变成致命杀手!对于一个开发者而言,则设计开发方案时,必定要将方案考虑周全。怎样才能将方案考虑周全?惟有持续不断地学习!