抢票把我灌醉,抢票让我流泪

1,192 阅读9分钟

记录一次血泪教训...

背景

最近在公司负责一个抢票需求,也是自己第一次接触这样可能存在瞬时高并发的任务。首先介绍一下整体功能背景:

  • 公司本身有一个比较大体量的App平台,这个活动是需要以该App为入口,进行抢票操作的
  • 抢票活动持续十天,每天放出1000张门票,当天抢不完的,累积到下一天
  • 门票可以一次抢一张、两张、三张
  • 鉴于疫情期间,活动流量不确定因素比较多,产品和TM都对活动流量不抱有乐观态度

以上就是本次需求的大致背景。

需求分析

拿到需求,话不多说,吭哧吭哧开干。

包含抢票、秒杀等字眼的需求,第一反应就是用redis来减库存,然后和TM确定一下活动可能的并发数,说是能支持300并发即可(但其实我在当时对这个并发数没有一个很具体的概念,只是听到TM轻松愉快的语气,就感觉这并不是一个很高的量,后来,我果然为自己的无知付出了代价)。

image.png

需求实现

于是我决定不引入redis了,直接在Mysql中进行减库存操作(埋雷)。

首先创建一张库存表(忽略一些非关键字段):

CREATE TABLE `ticket` (
  `id` int(32) NOT NULL auto_increment COMMENT '主键',
  `inventory` int(32) NOT NULL COMMENT '库存',
  ...
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='门票库存表';

由于公司用的mybatis plus,更新自带乐观锁机制,所以不用担心并发扣减库存的数量不正确问题。

然后我天真的将库存保存在一条语句里,设置重试机制,for循环一千次,更新失败则重新查询库,一千次都没更新成功,则提示失败。

然后功能提测,功能当然没问题,逻辑很简单。

功能测试结束后,正常就可以开始对接口进行压测,此时距离功能上线,还有一周左右的时间,觉得压测出什么问题,也来得及改,躺平。但是此时出现了一些插曲,需求有了一些功能性变更,而且领导觉得把这个抢票业务放在原本的App平台里,不妥,毕竟原先的App是一个已经耦合了够多的内容,这种一次性业务,不要再耦合进来了,不能影响原来的业务进行。

遂进行功能拆分,把原先写好的抢票业务搬运到一个新的框架中,这吭哧吭哧一搞,竟然把压测拖到了上线的前一天!

image.png

压测

准备第一轮压测

运维小伙伴开始运行压测程序,300并发,应用挂了...

image.png

分析原因

单条库存数据并发更新,并发量其实是很低很低的,因为更新的时候会锁行,并发量一上来,会有大量的请求等待锁,响应时间变长,会存在大量的连接数据库超时,拖垮应用。

那现在怎么办?

两种方案:

  • 改用redis

  • 在Mysql中将库存拆分

第一种方案如果在时间充裕的情况下,肯定是必选的。但是现在面临一个问题就是:没有时间!其实如果抢票都是一张一张的扣减,那么redis实现完全来得及改,但现在的需求是门票有一张、两张、三张三种情况,用redis来实现,改动可能会比较大。为了不影响上线,只能选择将库存拆分。

吭哧吭哧开始改了,1000张门票,拆成100条库存记录,每条记录十条。

查库存时,查出所有库存 > 0的记录,然后取一条来更新。但是现在由于库存拆分了,所以可能会存在一种情况:就是单条记录的库存不足,但是总库存充足,所以额外写了判断单条库存的是否充足的逻辑。

提测,功能没问题。

准备第二轮压测

先来50并发,应用没挂,但是RT还是比较高,所以TPS很低

image.png

然后考虑再拆库存记录,改成1000行,每条1库存。再压,100并发,应用又挂了...

image.png

image.png

分析原因

运维同学通过监控查看,行锁还是非常多。将库存分成多份存到多条记录里面,扣减库存的时候路由一下,这样子增大了并发量,但是还是避免不了大量的去访问数据库来更新库存,由于查询库存其实是有默认顺序的,所以更新库存时,其实大多数的线程,还是在争抢查询结果中排在前面的记录。知道了原因,改起来就容易了:

// 是否存在一条记录可以直接扣减票数
List<TicketInventoryDO> ticketInventoryForOne = ticketInventories.stream().filter(e -> e.getInventory() >= numbers).collect(Collectors.toList());
if (ticketInventoryForOne.size() > 0) {
    // 创造一个没有重复随机数的set
    int index = random.nextInt(ticketInventoryForOne.size());
    // 随机取记录
    TicketInventoryDO ticketInventory = ticketInventoryForOne.get(index);
    ticketInventory.setInventory(ticketInventory.getInventory() - numbers);
    if (!this.updateById(ticketInventory)) {
        // 更新失败则抛异常,回滚
    }
} else {
    // 单条记录存在不足扣减的情况,就需要先查询总库存是否充足
    int inventory = ticketInventories.stream().mapToInt(TicketInventoryDO::getInventory).sum();
    int reduceNum = numbers;
    Set<Integer> indexSet = new HashSet<>();
    if (inventory >= numbers) {
        while (true) {
            // 库存扣减完成
            if (reduceNum == 0) {
                break;
            }
            int index = -1;
            while (true) {
                index = random.nextInt(ticketInventories.size());
                if (!indexSet.contains(index)) {
                    indexSet.add(index);
                    break;
                }
            }
            TicketInventoryDO ticketInventory = ticketInventories.get(index);
            // 如果该条记录库存大于或等于需要扣减的库存
            if (ticketInventory.getInventory() >= reduceNum) {
                ticketInventory.setInventory(ticketInventory.getInventory() - reduceNum);
                reduceNum = 0;
            } else {
                // 如果该条记录的库存小于需要扣减的库存
                reduceNum = reduceNum - ticketInventory.getInventory();
                ticketInventory.setInventory(0);
            }
            if (!this.updateById(ticketInventory)) {
                // 更新失败则抛异常,回滚
            }
        }
    } else {
        // 总库存不足
    }
}

这段逻辑的目的,就是为了从查询的结果集中,随机取一条来更新,就可以避免大量线程的争抢同一条记录的锁。

提测,功能没问题。

准备第三轮压测

先来50并发

image.png

可以看到RT明显低了很多,TPS就上去了,而且没有存在数据库超时现象。

再压100并发

image.png

可以看到RT高了,TPS也降下来了,存在少量的数据库连接超时,感觉这一波已经到瓶颈。由于当时已经夜里一点半两点了,领导觉得这个活动这个并发量足够了,接口加上限流,应该可以抗住,主要第二天还要早期支撑第一波正式的抢票,功能压测就到这了。

距离TM提出的支持300并发,差了好多,真的很惭愧,还记得接下需求时和TM谈笑风生地聊这个活动的流量,一脸轻松写意的样子,到最后才发现小丑竟是我自己...

image.png

功能上线后,安稳地度过了前面几天(第一天库存数据初始化错误,导致第一天就超卖了20张票...这个是细心问题,低级错误,重打八十大板),到抢票最后一天时,突然大量流量涌入,把应用打挂了...一阵鸡飞狗跳地修复...

后续复盘原因,原来当时只给抢票接口加了限流,确实是可以保障该接口不被流量摧毁,但是,这是一个应用,这个应用可不止这一个接口...抢票的最后一天,因为活动已经开始了,大量的用户在现场都在查询自己票务信息,导致抢票接口并没有达到限流的标准,就开始出现超时(因为资源被别的请求占用了),然后就是雪崩效应,超时的接口越来越多,应用又叒叕挂了...

image.png

而且由于大量的慢SQL,导致查询库存变得十分缓慢,很多用户在点击抢票后,看到界面没有反应,又重新进入页面进行抢票操作,导致一些用户出现抢多张的情况(因为原先靠数据库做的幂等性已经不生效了,因为第一条请求的数据根本还没入库...)。关于此处的幂等校验,后续再专门写一篇展开来说。

ps:后续有另外一个抢票接口,直接使用redis来减库存,压测轻松达到200并发。不过该抢票接口的库存扣减是一张一张来的,而且也不会累积,使用redis比较容易实现,大体思路就是:

在redis中创建一个key,并设置超时时间(根据你的需求来,一般都是当天库存,那么key的超时时间就能设置24小时即可),每抢一次票,利用redis的RedisAtomicLong中的getAndIncrement()方法进行自增(该方法是原子性的,大胆用),当这个redis的key超过了你的库存值,就返回没票了即可。代码如下:

/**
 * redis自增
 *
 * @param key key
 * @param liveTime 过期时间(小时)
 * @return 自增数
 */
private Long incr(String key, long liveTime) {
    RedisAtomicLong entityIdCounter = new RedisAtomicLong(key, redisTemplate.getConnectionFactory());
    long increment = entityIdCounter.getAndIncrement();

    // 初始设置过期时间
    if (increment == 0 && liveTime > 0) {
        entityIdCounter.expire(liveTime, TimeUnit.HOURS);
    }
    return increment;
}

(后续再研究一下利用redis支持扣减多张库存、以及如果碰上门票抢不完可以累积的需求,应该怎么做,一次把减库存这事玩透)

总结

经历了这次抢票的洗礼,使我对并发知识的敬畏之心又多了几分...以前看过的知识,都是理论,你得真正经历一些什么,才能真正成长:

  • 你需要真正地思考架构的合理性(流量大的服务,是否需要拆分)
  • 压测,越早进行越好,这点很重要(我知道很多程序员都对自己的代码有信心,但只有经历压测,你才知道你的代码站不站得住脚,越临近上线,你改代码的质量越低)
  • 急急忙忙上线的功能,99.99%会出问题
  • 接口的限流,得考虑到应用的其他功能带来的资源占用
  • 并发量高的减库存操作,优先使用redis(可以说是毫不犹豫地pass掉关系型数据库)