哔哩哔哩技术 04月06日 13:20
B站票务抢购下单流程演进
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文分享了B站会员购票务系统在应对高并发抢购场景下的下单链路优化实践。通过三阶段迭代,包括同步事务处理、异步下单和Redis缓存扣减库存,系统成功提升了下单吞吐量和稳定性,最终保障了大型项目的票务销售。文章详细介绍了优化目标、演进过程、技术细节和效果,并探讨了限流、缓存优化等关键技术,为类似场景提供了宝贵的经验。

🚀 **初始阶段:同步事务处理**:为了快速上线功能,采用同步事务处理方式,但面临高并发下大事务阻塞、行锁竞争等问题,导致服务雪崩风险。

💨 **异步下单优化**:引入异步下单机制,解耦请求接收与事务执行,通过批量冻结库存、并行校验优惠券等方式,降低数据库压力和响应时长,但用户体验受到轮询等待的影响。

💡 **Redis缓存扣减库存**:采用Redis预扣库存、数据库兜底、支付回调异步化等措施,显著提升了系统并发处理能力,并辅以缓存优化和限流策略,保障了系统的稳定运行。

✅ **关键技术与效果**:文章深入探讨了缓存击穿、大Key处理、热Key处理等优化策略,以及网关限流、业务单机限流等限流措施。最终,系统成功应对了大规模抢购,并在24年BW项目中承接了高并发流量,保障了票务销售的稳定进行。

原创 业务线 2025-03-14 12:03 上海

围绕流量峰值承载、数据库压力优化、用户等待时长缩短三大核心目标,我们对下单链路进行了三阶段迭代。

1. 背景


bilibili 会员购票务从2017年从0-1开始,目前业务覆盖全国绝大部分2次元及2.5次元的展览、演出等项目。比如展览方面,有B站自己主办的BiliBili World(简称BW),Bilibili Macro Link(简称BML),和各种协作承办的漫展等。目前会员购票务在漫展垂直的市场率在行业中处于TOP级领先地位。

bilibili 会员购票务提供多种业务形态,包括:

    抢票功能:帮助用户抢购热门活动票,避免黄牛抢购风险场景发生。

    电影票:提供便捷的电影票购买通道,接通站内电影营销。

    选座功能:支持用户自主选择座位。

    检票工具:提升现场入场效率,体现平台服务独特性

    结算:业内高效、快速对账结算体系。

这些服务为用户提供便捷的票务体验,涵盖了从购票到入场到结算全流程需求。


项目列表 ➡️ 项目详情 ➡️ 结算页 ➡️ 订单详情


由于近年来漫展、电影等文化产业的消费力复苏,系统频繁承载高并发抢购场景(如漫展门票)。然而,热门项目库存远低于市场需求,传统架构在高并发场景下面临性能瓶颈。如何保障系统稳定性与用户体验,成为核心挑战。


2. 演进目标


对于一个热门的抢购项目,用户的基本访问链路为:项目列表/收藏 -> 商详 -> 选中对应的场次已经票种 -> 结算页补充下单信息 -> 下单。在上述链路中,其中商详以及项目列表基本都是一些固定的信息(商详页面的是否售罄用户也可以接受短期的延迟),所以这部分流程基本可以通过缓存去做处理,而大部分用户在结算页补充完个人信息之后,会对于创单接口进行多次且频繁的访问。下单接口流量大、实时性要求高,且直接影响交易收入转化,所以保障下单接口稳定成为重中之重。

围绕流量峰值承载、数据库压力优化、用户等待时长缩短三大核心目标,我们对下单链路进行了三阶段迭代。


3. 演进进程


随着会员购票务在漫展市场占比逐渐增加,票务的下单接口也经历过了以下三个阶段:


3.1 初始版本 - 同步事务处理


一:背景


在票务从0-1的过程中,为了增加市场的占有率,需要实现功能的快速迭代,这个过程中对于方案的考虑基本以实现基础功能为主。


二:方案


流程逻辑:用户请求实时同步处理库存扣减与订单写入。



三:效果


初始版本过程中,足以应对普通流量下的用户下单需求,然而当出现抢购的场景是,大事务以及库存DB的单行扣减问题的会影响到接口的性能以及接口响应时间,甚至出现库存死锁等问题。从而导致服务的雪崩。



痛点分析:

✅ 高并发下大事务阻塞——DB连接池耗尽,响应延迟飙升。

✅ 单行库存热点——InnoDB行锁竞争引发死锁,扣减失败率超30%。

✅ 无弹性扩展能力——服务雪崩风险显著。


3.2 异步下单 - 异步削峰,

降低响应时长以及DB压力


一:背景


针对于抢购的场景,为了避免出现因为库存以及大事务场景下影响用户下单,为了解决这种情况,衍生出了异步下单的版本:


二:方案


流程逻辑:解耦请求接收与事务执行,引入异步分批处理。

🔹前端交互:
用户获取下单Token,轮询查询结果(平均等待5-8秒)。
🔹后端处理:
✅ 库存批量冻结:合并SQL减少DB操作频次。
✅ 优惠券并行校验:拆分耦合模块提升吞吐量。

针对于用户下单接口中的容易导致DB出现异常的模块,进行异步削峰处理,将用户下单请求以及实际下单接口,拆分为两个模块处理,并且将下单批量化处理,减少DB的操作次数,降低DB的风险。



此时用户下单分成了三个步骤:

1.  用户下单,获取下单标识(唯一token)



2. 定时任务,批量下单,将之前的单条库存扣减、订单插入修改为进行批量冻结库存,并行冻结优惠券,批量合并sql插入数据库,最大限度上减少性能消耗



3. C端在下单页新增轮询接口根据唯一token轮询下单结果


三:效果


此方法很大程度上解决了数据库压力问题,但用户体验因轮询等待下降。


3.3 redis缓存扣减库存下单


一:背景


异步下单已经能够解决大部分的热门项目的抢购问题,但是自从2022年之后,漫展的市场热度出现了巨大的变化,上海CP的项目抢购打了我们一个措手不及。在抢购之前预估流量为平时流量的2倍,然而实际结果是比预估的流量大了10倍不止,紧随其后的BW的抢购更是说明了异步下单已经不能保证一些热门项目的抢购, 对于下单接口需要进一步的改造。

在之前的异步下单链路中还是存在几个问题

    前端用户体验,前端轮询下单结果,会有一个较长时间的等待。

    支付回调流量不可把控,如果支付回调QPS过高,也会导致库存单行扣减压力

    整个流程都是串行处理,如果下游接口响应耗时过高,会导致服务雪崩问题。

所以还需要对下单链路进行处理

    库存单行扣减优化

    接口部分调用串行转成并行处理,降低接口响应耗时,提升服务处理速度


二:方案


架构升级:

🔹 库存分层设计:

    Redis预扣(90%库存):通过Lua脚本保证原子扣减,提升下单库存性能

    DB兜底(10%库存):故障熔断时启用,结合库存校准机制防超卖(日志回溯+定时校准)。

🔹 支付回调异步化:

    临时表削峰:支付成功后写入待扣减记录,定时任务批量处理DB库存。

    并行化链路:优惠券核销、积分计算等模块异步执行,接口响应下降。


对于库存的扣减优化,主要有两点:

1.  下单扣减库存从DB扣改为redis扣,具体处理方式为:

a.  下单减库存,取全量库存的90%放入Redis进行扣减,这样做的目的是在Redis出现不可用的情况下,可以有部分数据库库存承接一定的流量等待库存校准完成。
b.  Redis扣减失败会通过数据库进行扣减,失败达到阈值触发库存校准,并关闭热点标。
c.  库存校准:每次缓存库存操作会记录日志,用于校准数据库库存,避免因Redis超时重试等情况产生的超卖少卖。



2.  支付回调后扣减进入临时表缓冲

支付回调存在了QPS不稳定的风险,并且因为涉及到订单状态的变更,也不能进行限流操作,但是因为此处库存不影响前台项目的销售,所以接受一定程度的延迟,在支付回调过程中,将需要扣减的冻结库存暂时写入到一张临时表中,通过定时任务的方式做批量化处理,既起到了削峰的作用,也降低了库存的操作频次,大大降低了DB的热点数据问题。



同时,为了系统性地提高整体的读写并发度,我们做了如下梳理和优化

1.  应用节点(链路)过长

    强弱依赖梳理,提供降级开关

    缩短RT时长,提升下单链路的各个接口性能

    规整服务告警和异常处理机制,尽量收缩核心链路范围,保证爆发期主链路的服务可用率达到SLA(下图是focus主链路异常处理和SLA监控的梳理逻辑)



2.  数据库

    sql慢查询优化

    DB主从数据同步延迟:下单链路查主库

    数据库表优化:合表减少减少查表次数等

    大事务:将数据库查询工作和rpc调用移除事务

    扫描资源消耗过大的定时任务做提前预案,抢票时提前降级:改用databus消费

    锁等待:避免非抢票相关的链路导致数据库加锁

    数据库隔离级别:RR改为RC


此处增加一些细节阐述数据库事务模式RR和RC的区别,以及为什么在高并发场景下使用RC更合适


RR和RC的区别

一致性读

一致性读,又称为快照读。快照即当前行数据之前的历史版本。快照读就是使用快照信息显示基于某个时间点的查询结果,而不考虑与此同时运行的其他事务所执行的更改。

在MySQL 中,只有READ COMMITTED 和 REPEATABLE READ这两种事务隔离级别才会使用一致性读。

在 RC 中,每次读取都会重新生成一个快照,总是读取行的最新版本。

在 RR 中,快照会在事务中第一次SELECT语句执行时生成,只有在本事务中对数据进行更改才会更新快照。

在数据库的 RC 这种隔离级别中,还支持"半一致读" ,一条update语句,如果 where 条件匹配到的记录已经加锁,那么InnoDB会返回记录最近提交的版本,由MySQL上层判断此是否需要真的加锁。


锁机制

数据库的锁,在不同的事务隔离级别下,是采用了不同的机制的。在 MySQL 中,有三种类型的锁,分别是Record Lock、Gap Lock和 Next-Key Lock。

Record Lock表示记录锁,锁的是索引记录。

Gap Lock是间隙锁,锁的是索引记录之间的间隙。

Next-Key Lock是Record Lock和Gap Lock的组合,同时锁索引记录和间隙。他的范围是左开右闭的。

在 RC 中,只会对索引增加Record Lock,不会添加Gap Lock和Next-Key Lock。

在 RR 中,为了解决幻读的问题,在支持Record Lock的同时,还支持Gap Lock和Next-Key Lock;


主从同步

在数据主从同步时,不同格式的 binlog 也对事务隔离级别有要求。

MySQL的binlog主要支持三种格式,分别是statement、row以及mixed,但是,RC 隔离级别只支持row格式的binlog。如果指定了mixed作为 binlog 格式,那么如果使用RC,服务器会自动使用基于row 格式的日志记录。

而 RR 的隔离级别同时支持statement、row以及mixed三种。


为什么选择使用RC

首先,RC 在加锁的过程中,是不需要添加Gap Lock和 Next-Key Lock 的,只对要修改的记录添加行级锁就行了。

这就使得并发度要比 RR 高很多。

另外,因为 RC 还支持"半一致读",可以大大的减少了更新语句时行锁的冲突;对于不满足更新条件的记录,可以提前释放锁,提升并发度。

减少死锁

因为RR这种事务隔离级别会增加Gap Lock和 Next-Key Lock,这就使得锁的粒度变大,那么就会使得死锁的概率增大。

带来的问题

首先使用 RC 之后,就需要自己解决幻读的问题。还有就是使用 RC 的时候,不能使用statement格式的 binlog,这种影响其实可以忽略不计了,因为MySQL是在5.1.5版本开始支持row的、在5.1.8版本中开始支持mixed,后面这两种可以代替 statement格式。


RR和RC在限购流程中的影响



如何通过“RC+显示锁”强制避免幻读

尽管RC级别不自动防幻读,但可通过 业务层加锁控制 来弥补。常用方案:


方案1:显式加行锁

    -- 事务ABEGIN;SELECT * FROM orders WHERE user_id=1 AND product_id=100 FOR UPDATE; -- 对已有行加行锁(假设当前无历史订单,此查询无数据,无法锁定任何行)
    -- 此时加锁失败,其他事务仍可插入相同条件的订单

      缺陷:若当前无符合条件的记录(如首次购买),SELECT ... FOR UPDATE 无法锁定未存在的行或范围,锁机制失效。


    方案2:唯一索引强行约束

    通过数据库唯一索引作为兜底:

      ALTER TABLE orders ADD UNIQUE INDEX idx_user_product (user_id, product_id);

      当并发INSERT触发唯一键冲突时,第二个事务会直接报错,业务代码需对这些错误重试或拦截。

        优点:绝对安全,数据库层物理拦截;

        缺点:

        仅适用于“单对象严格唯一性”场景(如一人一商品限购一次);

        若限购规则是N次(N>1),需改用计数器模式(如Redis原子操作)。


      方案3:分布式锁 + 内存计数器

      使用Redis或其他分布式协调服务(如ZooKeeper)执行原子操作:

        -- Lua脚本实现原子化操作(示例)local key = "limit:user_1:product_100"local limit = 2local current = redis.call('GET', key) or 0if tonumber(current) < limit then    redis.call('INCR', key)    return "OK"else    return "EXCEED_LIMIT"end


        由以上分析可知,无论选择RC、RR级别事务模式,都无法只通过数据库实现多条件动态限购,必须额外通过 显式分布式锁或缓存计数 确保逻辑原子性——将并发冲突拦截在业务层。


        3.  此外,我们还从以下三个方面做了一些优化,以期尽可能提高Redis缓存命中率和性能,从而增加整体的读写并发度

        a.  缓存击穿/穿透/连接等待的深度优化

          缓存击穿

        问题本质:热点Key失效瞬间的高并发穿透

        解决方案:

          逻辑过期时间:

        存储数据时附加过期时间戳,异步更新缓存

          多级缓存架构

        L1本地缓存(Caffeine) + L2 Redis缓存 + DB


          缓存穿透

        问题本质:恶意/异常请求不存在的数据
        解决方案:

          布隆过滤器

          // 初始化布隆过滤器BloomFilter<String> bloomFilter = BloomFilter.create(    Funnels.stringFunnel(Charset.forName("UTF-8")),     1000000,     0.01);
          // 查询流程if (!bloomFilter.mightContain(key)) {    return null; // 直接拦截}

            空值缓存:设置短TTL的null值(建议5-30秒)

            请求校验:业务层增加参数合法性校验


            连接等待优化

          问题本质:连接池资源竞争导致延迟

          优化方案:

            # Lettuce连接池配置示例

            spring.redis.lettuce.pool:  max-active: 20      # 根据QPS计算:(平均请求耗时(ms) * 峰值QPS) / 1000  max-idle: 10  min-idle: 5  max-wait: 50ms      # 超过阈值触发扩容  test-while-idle: true  time-between-eviction-runs: 60s

            监控指标:

              连接获取时间(redis.pool.wait.duration)

              活跃连接数(redis.pool.active)

              等待线程数(redis.pool.queued-threads)


            b.大key按片水平拆分

              大key识别标准

              String类型:Value > 10KB

              Hash/List/Set/Zset:元素数量 > 5000 或 总大小 > 10MB

              拆分策略:水平拆分

              // 基于哈希分片int shard = Math.abs(key.hashCode()) % 1024;String shardKey = "user:" + shard + ":" + userId;
              // 基于范围分片String shardKey = "order:" + (orderId >> 16) + ":" + orderId;


              c.热key

                延长非高频变Key的过期时间

                多级缓存保证命中率,例如ProjectInfo存在本地缓存,查询顺序:本地缓存>redis缓存->db,添加降级缓存做兜底

                后置校验的方式控制数据不一致风险:提前预热/更新redis缓存


              d.综合性能优化矩阵


              优化维度

              关键技术点

              预期收益

              复杂度

              缓存命中率

              布隆过滤器 + 多级缓存

              提升30%-50%命中率

              大Key处理

              自动分片 + 数据压缩

              降低90%单节点负载

              热Key处理

              本地缓存 + 读写分离

              提升10倍读取吞吐量

              连接效率

              连接池优化 + Pipeline批量操作

              降低50%网络延迟


              通过以上系统性优化方案,可以在保证数据一致性的前提下,将Redis缓存命中率提升至90%以上,同时显著降低数据库负载和响应延迟。


              4.  除了尽可能提高系统的读写并发度,我们也尽可能为每个接口配置了兜底的限流策略,防止流量过载情况下对系统的雪崩型打击:

                网关集群限流

                业务单机限流:Guava RateLimiter,这里可以额外介绍集中生产级限流器的特性

                预热机制:


                // Guava RateLimiter的预热实现RateLimiter.create(permitsPerSecond, warmupPeriod, timeUnit);


                  动态配置:

                  支持运行时调整速率阈值

                  基于QPS自动弹性扩缩


                  分级降级:

                  请求量区间       策略---------------|-----------------< 50%阈值      | 正常处理50%-80%阈值    | 延迟响应80%-100%阈值   | 返回缓存数据> 100%阈值     | 直接拒绝


                    流量染色:

                    对通过/拒绝的请求添加标记头

                    实现全链路限流控制


                  三:效果


                  经过对于接口的优化处理、以及库存扣减避免缓存热点之后,目前已经可以支持绝大部分的项目抢购。在24年BW项目抢购中,我们承接了93w/s的WEBCDN峰值流量,30w/s +服务峰值流量,并顺利保障了BW票务销售的稳定和业务目标的达成。


                  4. 总结


                  我们进行的几次链路优化、异步下单改造、库存缓存扣减等措施上线后,票务系统的下单吞吐量和稳定性都得到了较大的提升,也逐渐可以承接更大型规模项目的抢票需求。然而还存在着较多的优化点,我们将持续改造,为用户提供更好的购票体验。


                  -End-

                  作者丨周超、叶问


                  开发者问答

                  关于秒杀系统设计,大家还有什么优秀的方案和经验?

                  欢迎在留言区分享你的见解~

                  转发本文至朋友圈并留言,即可参与下方抽奖⬇️

                  小编将抽取1位幸运的小伙伴获取小电视鼠标垫键盘垫

                  抽奖截止时间:3月18日12:00

                  如果喜欢本期内容的话,欢迎点个“在看”吧!




                  往期精彩指路


                  通用工程大前端业务线

                  大数据AI多媒体


                  阅读原文

                  跳转微信打开

                  Fish AI Reader

                  Fish AI Reader

                  AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

                  FishAI

                  FishAI

                  鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

                  联系邮箱 441953276@qq.com

                  相关标签

                  B站 会员购 下单链路 高并发 系统优化
                  相关文章