干货 | 1分钟售票8万张!门票抢票背后的技术思考

988 阅读13分钟

一、背景

去年疫情后,为了加速启动旅游市场,湖北在全域范围内开展“与爱同行 惠游湖北”活动——全省所有A级旅游景区向全国游客免门票,敞开怀抱欢迎全国人民。本文将介绍在这一活动期间,线上预约抢票系统遇到的核心问题,系统的改造过程以及实施的一些经验。这是高并发、高可用场景下,提升系统稳定性的一次实战优化,希望能给面对同样问题的同学提供一些借鉴思路。

图片

活动页面

二、风险与挑战

在活动初期,系统面临以下四类风险:

  • 流量大,入口流量瞬间增长100倍,远超系统承载能力;

  • 高并发下,服务稳定性降低;

  • 限购错误;

  • 热门门票、热门出行日期扣库存热点;

图片

高并发下系统的挑战

下面我们一起来看下每个问题的影响和解决策略。

2.1 入口流量增长100倍

问题

活动开始时入口流量增长100倍,当前系统无法通过水平扩展解决问题。

图片

请求量监控

目标

提升入口应用吞吐能力,降低下游调用量。

策略

减少依赖

1)去除0元票场景不需要的依赖。例如:优惠、立减;

2)合并重复的 IO(SOA/ Redis/DB),减少一次请求中相同数据的重复访问。

图片

上下文传递对象减少重复IO

提升缓存命中率

这里说的是接口级缓存,数据源依赖的是下游接口,如下图所示:

图片

服务层-接口级缓存-固定过期

接口级缓存一般使用固定过期+懒加载方式来缓存下游接口返回对象或者自定义的DO对象。当一个请求进来,先从缓存中取数据,若命中缓存则返回数据,若没命中则从下游获取数据重新构建缓存,由于是接口级的缓存,一般过期时间设置都比较短,流程如下图:

图片

固定过期+懒加载缓存

这种缓存方案存在击穿和穿透的风险,在高并发场景下缓存击穿和缓存穿透问题会被放大,下面会分别介绍一下这几类常见问题在系统中是如何解决的。

1)缓存击穿

描述:缓存击穿是指数据库中有,缓存中没有。例如:某个 key访问量非常高,属于集中式高并发访问,当这个 key 在失效的瞬间,大量的请求就击穿了缓存,直接请求到下游(接口/数据库),造成下游压力过大。

解决方案:对缓存增加被动刷新机制,在缓存实体对象中增加上一次刷新时间,请求进来后从缓存获取数据返回,后续判断缓存是否满刷新条件,若满足则异步获取数据重新构建缓存,若不满足,本次不更新缓存。通过用户请求异步刷新的方式,续租过期时间,避免缓存固定过期。

例如:商品描述信息,以前缓存过期时间为5min,现在缓存过期时间为24H,被动刷新时间为1min,用户每次请求都返回上一次的缓存,但每1min都会异步构建一次缓存。

2)缓存穿透

描述:缓存穿透是指数据库和缓存中都没有的数据,当用户不断发起请求,比如获取id不存在的数据,导致缓存无法命中,造成下游压力过大。

解决方案:当缓存未命中,在下游也没有取到数据时,缓存实体内容为空对象,缓存实体增加穿透状态标识,这类缓存过期时间设置比较短,默认30s过期,10s刷新,防止不存在的id反复访问下游,大部分场景穿透是少量的,但是有些场景刚好相反。例如:某一类规则配置,只有少量商品有,这种情况下我们对穿透类型的缓存过期时间和刷新时间设置同正常的过期和刷新时间一样,防止下游无数据一直频繁请求。

3)异常降级

当下游出现异常的时候,缓存更新策略如下:

缓存更新:

  • 下游是非核心:超时异常写一个短暂的空缓存(例如:30s 过期,10s刷新),防止下游超时,影响上游服务的稳定性。

  • 下游是核心:异常时不更新缓存,下次请求再更新,防止写入空缓存,阻断了核心流程。

4)缓存模块化管理

将缓存key按照数据源做分类,每一类key对应一个缓存模块名, 每个缓存模块可以动态设置版本号、过期时间和刷新时间,并统一埋点与监控。模块化管理后,缓存过期时间粒度更为细致,通过分析缓存模块命中率监控,可以反推过期和刷新时间是否合理,最终通过动态调整缓存过期时间与刷新时间,让命中率达到最佳。

图片

缓存模块命中率可视化埋点

我们将以上功能封装为了缓存组件,在使用的时只需要关心数据访问实现,既解决了使用缓存本身的一些共性问题,也降低了业务代码与缓存读写的耦合度。

下图为优化前后缓存使用流程对比:

图片

缓存使用对比

效果

通过解决缓存穿透与击穿、异常降级、缓存模块化管理,最终缓存命中率提升到98%以上,接口性能 (RT) 提升50% 以上,上下游调用量比例从1 : 3.9 降低为 1 : 1.3,下游接口调用量降低70%。

图片

处理性能提升50%

2.2 高并发下服务稳定性低

问题

在每天上午8:00抢票活动开始时,DB连接池被打满,线程波动大,商品服务超时。

图片

数据库线程波动

思考

  • DB 连接池为什么会被打满?

  • API为什么会超时?

  • 是DB不稳定影响了API,还是API流量过大影响了DB?

问题分析

1)DB 连接池为什么会被打满?分析三类SQL日志。

  • Insert 语句过多 – 场景:限购记录提交,将限购表单独拆库隔离后,商品API依然超时(排除)

  • Update 语句耗时过长 – 场景:扣减库存热点引起(重点排查)

  • Select 高频查询 – 场景:商品信息查询

2)API为什么会超时?

排查日志可以看到,8:00活动开始后,大量热门商品信息查询到DB与Select高频查询一致。

3)是DB不稳定影响了API,还是API流量过大影响了DB?

根据#2初步判断是由于缓存击穿,导致大量流量穿透到DB。

为什么缓存会被击穿?

梳理系统架构后发现,由于8:00定时可售通过离线Job控制,8:00商品上线引发数据变更,数据变更导致缓存被刷新(先删后增),在缓存失效瞬间,服务端流量击穿到DB,导致服务端数据库连接池被打满,也就是上文所说的缓存击穿的现象。

图片

数据访问层-表级缓存-主动刷新

如下图所示,商品信息变更后主动让缓存过期,用户访问时重新加载缓存:

图片

数据访问层缓存刷新架构(旧)- 消息变更删除缓存Key

目标

为了防止活动时缓存被删除导致缓存击穿,流量穿透到DB,采用了以下2种策略:

1)避开活动时数据更新导致缓存失效

我们将商品可售状态拆分商品可见、可售状态。

  • 可见状态:7:00提前上线对外可见,避开高峰;

  • 可售状态:逻辑判断定时售卖,既解决定时上线修改数据后,导致缓存被刷新的问题,也解决了Job上线后,商品可售状态延迟的问题。

图片

逻辑判断定时可售避开高峰缓存击穿

2)调整缓存刷新策略

原缓存刷新方案(先删后增)存在缓存击穿的风险,所以后面缓存刷新策略调整为覆盖更新,避免缓存失效导致缓存击穿。新缓存刷新架构,通过Canal监听 MySQL binlog 发送的MQ消息,在消费端聚合后,重新构建缓存。

图片

数据访问层缓存刷新架构(新)- 消息变更重新构建缓存

效果

服务(RT)正常,QPS提升至21w。

图片

上面两类问题与具体业务无关,下面我们介绍一下两个业务痛点:

  • 如何防止恶意购买(限购)

  • 如何防止库存少买/超买(扣库存)

2.3 限购

什么是限购?

限购就是限制购买,规定购买的数量,往往是一些特价和降价的产品,为了防止恶意抢购所采取的一种商业手段。

限购规则(多达几十种组合)例如:

1)同一出行日期同一景区每张身份证只能预订1张;

2)7天内(预订日期)某地区只能预约3个景区且最多限购20份;

3)活动期间,预约超过5次,没有去游玩noshow限购;

问题

扣库存失败,限购取消成功(实际数据不一致),再次预订被限购了。

原因

限购提交是Redis和DB双写操作,Redis是同步写,DB是线程池异步写,当请求量过大时,线程队列会出现积压,最终导致Redis写成功,DB延时写入。在提交限购记录成功,扣库存失败后,需要执行取消限购记录。

如下图所示:

图片

限购检查-提交限购-取消限购

在高并发的场景下,提交限购记录在线程池队列中出现积压,Redis写入成功后,DB并未写入完成,此时取消限购Redis删除成功,DB删除未查到记录,最终提交限购记录后被写入,再次预订时,又被限购。

如下图:

图片

线程队列积压,先提交的“提交限购”请求晚于“取消限购”

目标

服务稳定,限购准确。

策略

确保取消限购操作Redis/DB最终一致。

由于提交限购记录可能会出现积压,取消限购时提交限购记录还未写入,导致取消限购时未能删除对应的提交记录。我们通过延迟消息补偿重试,确保取消限购操作(Redis/DB)最终一致。在取消限购的时候,删除限购记录影响行数为0时,发送MQ延迟消息,在Consumer端消费消息,重试取消限购,并通过埋点与监控检测核心指标是否有异常。

如下图所示:

图片

下单-提交限购与取消限购

效果

限购准确,没有误拦截投诉。

2.4 扣减库存

问题

  • 商品后台显示1w已售完,实际卖出5000,导致库存未售完。

  • MySQL出现热点行级别锁,影响扣减性能。

原因

  • 扣库存与库存明细SQL不在一个事务里面,大量扣减时容易出现部分失败的情况,导致库存记录和明细不一致的情况。

  • 热门景点热门出行日期被集中预订,导致MySQL出现扣减库存热点。

目标

库存扣减准确,提升处理能力。

策略

1)将扣减库存记录和扣减明细放在一个事务里面,保证数据一致性。

图片

DB事务扣减库存

效果

优点:数据一致。

缺点:热点资源,热门日期,扣减库存行级锁时间变长,接口RT变长,处理能力下降。

2)使用分布式缓存,在分布式缓存中预减库存,减少数据库访问。

秒杀商品异步扣减,消除DB峰值,非秒杀走正常流程。

图片

商品上线的时候将库存写入Redis,在活动扣减库存时,使用incrby原子扣减成功后将扣减消息MQ发出,在Consumer端消费消息执行DB扣减库存,若下单失败,执行还库存操作,也是先操作Redis,再发MQ,在Consumer端,执行DB还库存,如果未查询到扣减记录(可能扣库存MQ有延迟),则延时重试,并通过埋点与监控检测核心指标是否有异常。

图片

异步扣减库存

效果

  • 服务RT平稳,数据库IO平稳

  • Redis 扣减有热点迹象

3)缓存热点分桶扣减库存

当单个Key流量达到Redis单实例承载能力时,需要对单key做拆分,解决单实例热点问题。由于热点门票热门日期产生热点Key问题,观察监控后发现并不是特别严重,临时采用拆分Redis集群,减少单实例流量,缓解热点问题,所以缓存热点分桶扣减库存本次暂未实现,这里简单描述一下当时讨论的思路。

如下图所示:

图片

缓存热点分桶扣减

分桶分库存:

秒杀开始前提前锁定库存修改,并执行分桶策略,按照库存Id取模分为N个桶, 每个分桶对应缓存的Key为Key [0~ N-1],每个分桶保存m个库存初始化到Redis,秒杀时根据 Hash(Uid)%N 路由到不同的桶进行扣减,解决所有流量访问单个Key对单个Redis实例造成压力。

桶缩容:

正常情况下,热门活动每个桶中的库存经过几轮扣减都会扣减为0。

特殊场景下,可能存在每个桶只剩下个位数库存,预订时候份数大于剩余库存,导致扣减不成功。例如:分桶数量为100个,每个桶有1~2个库存,用户预订3份时扣减失败。当库存小于十位数时,缩容桶的数量,防止用户看到有库存,扣减一直失败。

优化前后对比

图片

扣减库存方案对比

三、回顾总结

回顾“与爱同行 惠游湖北”整个活动,我们整体是这样备战的:

  • 梳理风险点:包括系统架构、核心流程,识别出来后制定应对策略;

  • 流量预估:根据票量、历史PV、节假日峰值预估活动峰值QPS;

  • 全链路压测:对系统进行全链路压测,对峰值 QPS进行压测,找出问题点,优化改进;

  • 限流配置:为系统配置安全的、符合业务需求的限流阀值;

  • 应急预案:收集各个域的可能风险点,制作应急处理方案;

  • 监控:活动时观察各项监控指标,如有异常,按预案处理;

  • 复盘:活动后分析日志,监控指标,故障分析,持续改进;

本文阐述了在抢票活动中遇到的四个具有代表性的问题,在优化过程中,不断地思考和落地技术细节,沉淀核心技术,以最终达到让用户预订及入园顺畅,体验良好的目标。