B站票务抢购下单流程演进

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的抢购更是说明了异步下单已经不能保证一些热门项目的抢购, 对于下单接口需要进一步的改造。

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

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

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

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

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

  1. 库存单行扣减优化

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

二:方案

架构升级:

🔹 库存分层设计:

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

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

🔹 支付回调异步化:

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

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

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

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:显式加行锁

-- 事务A
BEGIN;
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 = 2
    local current = redis.call('GET', key) or 0
    if 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-

    作者丨周超、叶问

    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

    1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
    2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

    余额充值