故事背景
多年前,我在某旅游网站做酒店业务,和隔壁部门同事一起吃午饭,他讲一个事情。 他们负责的一次抢购秒杀活动中,某人写的代码在生产出现了超卖的情况。例如上架了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。