秒杀场景下-避免库存超卖-和高并发优化

故事背景

     多年前,我在某旅游网站做酒店业务,和隔壁部门同事一起吃午饭,他讲一个事情。 他们负责的一次抢购秒杀活动中,某人写的代码在生产出现了超卖的情况。例如上架了10套低价房源,最后有13人抢到了,多卖出去3套。 正常出现这种事情,其实就属于 严重事故 程序员和程序代码总是要有一个要跑的😂。 但是后面商家给的回复令人爆笑,商家却说 "太TM好了,要的就是这种效果~"
     这里也算当做了饭后打趣的故事,不过超卖这个技术问题,其实很恐怖的,例如1台手机价值5000元,秒杀上架1元,搞活动只卖10台,实际多卖了5台,这个损失最后总是要有人负责的。
     闲暇时想到这个事情,觉得要主动学习下这块,如果后面遇到类似的场景,该如何处理,避免犯类似的错误。

防止超卖

SQL乐观锁

1
2
# SQL逻辑: 版本号MVCC 同时只有库存>0才扣减库存
UPDATE stock = stock - 1 WHERE stock = oldStock AND stock>0 AND product_id = 123;

表字段结构兜底

1
2
# 库存字段值不可以为负数
ALTER TABLE products MODIFY COLUMN stock UNSIGNED NOT NULL DEFAULT 0;

Redis缓存层库存计数

1
2
3
4
# 方案一: Redis+Lua 保证一组操作的原子性,同时避免死锁。
Object seckillCount = redisTemplate.execute(script, Arrays.asList(SeckillRedisKey.SECKILL_STOCK_COUNT_HASH.getRealKey(round)), "-1");
# 方案二: Redis Increment
Long seckillCount = redisTemplate.opsForHash().increment(SeckillRedisKey.SECKILL_STOCK_COUNT_HASH.getRealKey(round),seckillId, -1);

分布式锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 创建锁对象-Redission内置看门狗,自动锁续期。
RLock lock = redissonClient.getLock("seckill:product:stock:lock:" + seckillId);
SeckillProductVo vo = null;
try {
// 加锁
lock.lock(10, TimeUnit.SECONDS);
// 查询最新的库存情况【DB直查】
vo = seckillProductService.findById(seckillId);
if (vo.getStockCount() > 0) { // 库存大于 0 才扣库存
// 扣除库存【乐观锁】
int row = seckillProductService.decrStockCount(seckillId);
if (row <= 0) {
// 库存数不足
throw new BusinessException(SeckillCodeMsg.SECKILL_STOCK_OVER);
}
}
// 创建订单对象
OrderInfo orderInfo = this.create(userId, vo);
// 保存订单对象
orderInfoMapper.insert(orderInfo);

return orderInfo.getOrderNo();// 秒杀成功,订单落库,恭喜用户~
} catch (BusinessException be) {
throw be; // 不处理继续往外抛出
} catch (Exception e) {
// 重新同步 redis 库存,设置本地库存售完标识为 false
if (vo != null && vo.getStockCount() > 0) {
this.rollbackStockCount(vo);
}
// 继续向外抛出异常
throw new BusinessException(500,"SYSTEM_ERROR");
} finally {
// 释放当前线程锁
if(lock.isHeldByCurrentThread() && lock.isLocked()){
lock.unlock();
}
}

单机场景

1
2
3
4
# 单机场景下,可以直接使用同步锁,性能较差,不支持分布式微服务场景。
synchronized(this){
// 库存查询扣减等业务逻辑
};

高并发性能优化

思考总结:时刻要有着保护数据库,缓存抗压,流量分层,降低IO,提高吞吐量,关键节点注意加锁的思维。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 1.前置进行各种业务校验,检查登录,检查活动是否进行,是否在灰度策略,挡掉大部分流量。
# 2.JVM本地缓存一份已经抢光的商品MAP,若存在该商品,直接返回,提高响应速度。【如果进行库存补偿,需要各POD都清空此处MAP】
public static final Map<Long, Boolean> LOCAL_STOCK_OVER_FALG_MAP = new ConcurrentHashMap<>();
# 3.检查用户是否已秒杀过该商品
# 4.Redis库存量检查
Long seckillCount = redisTemplate.opsForHash().increment(SeckillRedisKey.SECKILL_STOCK_COUNT_HASH.getRealKey(round),seckillId, -1);
if (seckillCount < 0) {
// 标记该秒杀商品已经卖完了
LOCAL_STOCK_OVER_FALG_MAP.put(seckillId, true);
return;
}
# 5.代码编写时,注意并发请求是否会对最终结果产生影响,注意加锁。
# 6.锁优化,降低锁的颗粒度,释放锁只释放本线程上的锁。
# 7.扣减库存持久化到DB。
# =====其他=====>
# 8.使用中间件,流量削峰,异步串行处理。
# 9.异步Servelt,提高吞吐量,提高性能。
# 10.提前评估请求量,服务端负载均衡,数据库多实例,缓存层多级缓存。理论上Nginx支持20w级别并发,Redis单机至高10w,MySQL至多几千,同时也受硬件磁盘读写能力影响。Java的SpringBoot内置的Tomcat上千左右。上线前也要考虑,是否进行配置调参优化或改用其他。
# 11.热点数据提前预热等等,各大电商公司,真实的生产解决方案应该会更高效和全面,希望有一天也有机会可以见识下。

其他杂谈

1.适当超卖其实也算一种真实的业务需求,再到后面也有看到携程的后台,后面也添加了允许"超卖"和超卖数量的功能。
2.当时所在的公司,办公室政治斗争多一些,对技术不太看重【或者说公司发展和行业都处于下行,无力拿高薪养技术人】,出现各种问题也就不奇怪了。
3.在后面去其他公司也证实了这一点,原公司的技术大佬好多后面去了其他大型公司,剩下留下来的都是搞办公室斗争或者情商极高或者忍耐力极强,的活下来的,这些人实际出去的话竞争力是很低的,公司整体技术能力是一直走下坡的,相反很多出去的人在外面发展相对是更好些的。
4.听老员工讲,当年上升期时门票业务可以和OTA一哥携程掰手腕,某事业部负责人年终奖1次拿过50个月工资,直接财务自由了。再到后面潮水退去,经营越来越难,甚至后面天天研究从员工身上搞钱,这些老员工表示真的好心寒。好似"眼看他起高楼,眼看他宴宾客,眼看他楼塌了"。
5.经营一门生意好难,做好一家公司好难,但是干死一家公司,搞死一个行业很容易。一纸文件+疫情时代,真的是冲击三观,令人唏嘘。
6.现在是2024年末,可以说国内实体经济是百业萧条,经济周期加后疫情时代,人民群众失去最重要的信心,各行各业都在收缩,很是艰难。互联网行业可以提前观察和感知到,短时间内应该很难再看到,网民疯狂在线购物的场景了,消费在降级,流量在分散,而头部企业也仅仅是吃掉存量的流量,商品价格也持续内卷到,消费者对这些活动无兴趣了。我曾原以为技术是多么重要多么厉害,但现实却是如此残酷,没有业务的技术也将是无用。
7.这家在线旅游公司现今已倒闭,2008-2024,互联网企业能生存16年也实属很不容易,它和旅游行业,基本也是被COVID-19和离谱的管控治理最终绝杀KO,至于凶手么,手动加个狗头0.0。