一、事故场景
有次,运营和商家做了个限量抢购活动,限量100件,但活动当天却超卖了,最终卖出的数量是160多件。这种超卖是比较严重的事故,出现了的话基本上和分布式锁有关系。
二、问题分析
项目中的抢购订单使用了分布式锁,而分布式锁的是基于Redis实现的,下面是订单抢购核心代码(使用伪代码讲解):
String key = "key:" + request.getSeckillId;
Boolean lockFlag = null;
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);
// 发送事件,异步生成订单
}
}
} finally {
// 释放锁
if (lockFlag) {
stringRedisTemplate.delete("key");
}
}
代码中给分布式锁设置10秒超时时间来保障业务逻辑有足够的执行时间,并且也对库存进行了校验,整块逻辑采用 try-finally
语句块来保证锁一定会及时释放,代码看起来很安全,平时也没有出现问题。
但问题就在于,中间有调用其他服务接口,并且在抢购活动开始的一瞬间,因为流量过大,导致调用所依赖的服务超时而锁失效。这个时候就会发生一连串的连锁反应:一开始获得锁的线程还没有执行完毕,锁就被另一个线程获取了,而第一个线程执行业务逻辑完毕后执行释放锁的操作时就会把第二个线程的锁给释放了,然后第三个线程再次获取锁,就这样陷入了恶性循环。
当然,虽然锁失去了作用,但还有个库存校验逻辑,但是偏偏库存校验逻辑不是非原子性的,代码中库存校验方式是先从 Redis 中 get 出库存数量,然后判断库存是否还有,最后再进行库存的扣减。这种库存校验的方式在锁正常的情况下也是可以的,但一旦锁失效就是不安全了。
所以,问题的根本原因在于库存校验严重依赖了分布式锁最终才导致超卖。
三、解决问题
从上面的分析可以知道,问题就出现在分布式锁和库存校验那里,所以,我们可以对症下药。
1、使用相对安全的分布式锁
相对安全的定义就是:加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。但即使是这样也无法保障业务的绝对安全,因为锁的过期时间始终是有界的,除非不设置过期时间或者把过期时间设置的很长,但这样做也会带来其他问题,没有意义。
而 redisTemplate.opsForValue().setIfAbsent()
就是对应 redis 的命令 set key value [EX seconds] [PX milliseconds] [NX|XX]
,这是安全的同时也是原子性的。所以我们只需要实现安全的释放锁即可。
要想实现相对安全的释放分布式锁,必须依赖 key 的 value 值。在释放锁的时候,通过 value 值的唯一性来保证不会勿删。我们基于 LUA 脚本实现原子性的安全解锁,封装方法如下:
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));
}
2、实现安全的库存校验
如果我们对于并发有比较深入的了解的话,会发现想 Redis 的 get and compare/ read and save 等操作,都是非原子性的。如果要实现原子性,我们可以借助 LUA 脚本来实现。但就我们这个例子中,由于抢购活动一次只能购买一件,所以可以不用基于LUA脚本实现而是基于 redis 本身的原子性:
// redis 操作完数据并返回操作结果的整个过程是原子性的
Long currStock = redisTemplate.opsForHash().increment("key", "stock", -1);
所以,代码中的库存校验是多余的,下面是优化后结果:
String key = "key:" + request.getSeckillId();
String val = UUID.randomUUID().toString();
try {
// 获取分布式锁
Boolean lockFlag = redisTemplate.opsForValue().setIfAbsent(key, val, 10, TimeUnit.SECONDS);
if (!lockFlag) {
// 业务异常
}
// HTTP请求调用其他服务接口
......
// 库存校验,基于redis本身的原子性来保证
Long currStock = stringRedisTemplate.opsForHash().increment(key + ":info", "stock", -1);
if (currStock < 0) { // 说明库存已经扣减完了。
// 业务异常。
log.error("[抢购下单] 无库存");
} else {
// 发送事件,异步生成订单
}
} finally {
safedUnLock(key, val);
}
四、方案优化
1、是否需要分布式锁
其实可以发现,我们借助于redis本身的原子性扣减库存,也是可以保证不会超卖的。对的。但是如果没有这一层锁的话,那么所有请求进来都会走一遍业务逻辑,由于依赖了其他系统,此时就会造成对其他系统的压力增大。这会增加的性能损耗和服务不稳定性,得不偿失。
2、分布式锁的选型
- 可以使用 Redission 来解决锁的存续问题;
- 也可以用 RedLock 来实现分布式锁。RedLock的可靠性更高,但其代价是牺牲一定的性能。在本场景,这点可靠性的提升远不如性能的提升带来的性价比高。如果对于可靠性极高要求的场景,则可以采用 RedLock 来实现。
3、能否用数据库做最终的防护
如果在 Redis 中扣减库存成功后进行数据库的同步操作,比如使用 set stock = stock - 1 where stock - 1
来保证不会超卖,将这做为最后的保障手段。但在高并发场景下操作数据库更新的话会有性能损耗,也会给数据库带来很大压力,但要论证多大才算大,以我的经验 mysql 简单字段的并发写 1000~
2000 qps是完全扛得住的,这需要压测来论证,当然如果并发太高也可以只使用缓存操作,异步机制同步文章来源:https://www.toymoban.com/news/detail-481403.html
在性能要求极高的场景下,一般数据以缓存为准,支付交易等也是如此,分布式场景下,大多数场景都是最终一致性。文章来源地址https://www.toymoban.com/news/detail-481403.html
到了这里,关于【开发经验】之记一次Redis分布式锁造成的事故的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!