在线库存设计

1.背景

        21年北京电影节是猫眼第一次承办的大型电影节售票业务,电影节售票的形态是定点开售,且热门影片的场次有限,票远远不够影迷分,所以会出现秒杀抢票的场景,而库存系统的设计是秒杀抢票的核心环节,电影节售票是在线选座秒杀抢票,对库存在高并发下的准确性、实时性要求更高,本文将给大家介绍北影节在线选座库存系统的设计方案。

2.业务场景

了解在线选座库存系统前先介绍下猫眼在线售票业务在哪些场景下会对库存操作。

  • 座位图=查询库存:查询未售、预占和已售库存,猫眼座位图展示包括未售、预占和已售库存,未售库存=总库存-已售库存-预占库存。
  • 锁座=预占库存:库存从未售状态变为预占状态,用户只要锁座成功就表示用户抢到票。
  • 解锁=释放库存:库存从预占状态变为未售状态,业务在2种情况下会触发解锁,第一种是主动型,用户手动取消锁座;第二种是被动型,用户锁座后15min内未支付,系统会默认解锁。
  • 出票=核销库存:库存从预占状态变为已售状态,用户支付完成后会异步出票,同时将库存状态变更。
  • 退票=释放库存:库存从已售状态变为未售状态,用户发起退票后,会退票同时将库存状态变更。

   
                                     

 

3.技术挑战&思考

  • 查询库存:高并发

秒杀抢票的流量入口是场次座位图界面,座位图展示主要是查询库存,北影座位图预计流量是20wQPS,所以查询库存的流量不能直接到数据库,最好的选择是通过缓存抗压。

  • 预占库存:唯一性

预占库存最关注的是数据的唯一性,不允许2个用户同时预占同一个座位,通过数据库唯一索引可以做到预占库存的唯一性,但需要考虑预占库存流量对DB写入的压力

  • 释放库存:一致性、原子性

释放预占、已售库存会同时修改DB的库存状态和缓存的库存状态,保障DB和缓存数据的一致性很重要,如果缓存有数据而DB写入失败,会导致少卖,而当缓存无数据DB有数据会影响用户体验,用户看到座位图是未售,但一直无法锁座,同时在高并发下对库存缓存的修改如何保障原子性操作也是我们需要考虑的。

  • 核销库存:异步操作

核销库存在交易异步出票流程触发,需要将座位图的状态从预占改为已售,接口流量并不会给库存系统带来压力。

从上面四个库存操作我们有了大致的解决思路,采用缓存来应对查询座位图的瞬时大流量,采用DB的唯一索引来保障库存的唯一性,避免重卖,如何保障修改座位图时缓存操作的原子性DB与缓存的数据一致性是我们需要面对的问题。

4.数据库设计

 库存系统由2张表组成,库存主表(stock)和库存流水表(stock_detail),座位图状态的核心状态是未售、预占和已售。

  • 预占库存:往库存流水表插入一条状态为预占的记录,show_id+seat_no建立唯一索引,来保证座位的唯一性
  • 释放库存:删除库存流水表对应的记录。
  • 核销库存:修改库存流水表状态为已售和更新库存主表的已售库存数,业务流量不大,所以通过事务保证DB操作的原子性。

5.设计方案一:小清新

5.1.业务逻辑

  • 查询库存:   redis采用set结构存储场次所有的已售库存和预占库存,缓存内容seatId的set集合,采用smembers指令可以获取所有已售+预售库存信息,再结合静态座位图信息可获取场次座位图的实时库存信息。
  • 预占库存:通过(场次ID+seatId)分布式锁来保障一个座位只有一个用户预占,也是拦截了大部分抢座用户,防止流量到数据库,锁座成功后插入DB库存流水表的状态为预占,然后通过setAdd将seatId信息写入缓存中。
  • 核销库存:修改DB库存流水表的状态为已售,同时修改DB库存主表的已售库存数,缓存中已经有该seatId信息,所以不需要写缓存。
  • 释放库存:先删除DB的库存流水,然后通过srem删除缓存中对应的seatId。

5.2.技术方案

高并发原子性:缓存使用了Set结构存储,所以写入、删除库存的操作都是原子性的,但查询库存的smembers指令是O(N)的操作,当场次座位数很多的时候,对查询座位图性能有很大影响。

一致性:在预占库存和释放库存2个业务中都存在同时操作缓存和DB的动作,如何保证DB和缓存数据一致性?我们先看下数据不一致的情况下会给业务带来什么问题。

  • 预占库存写入DB成功但写入缓存失败,会导致该座位实际被预占了,用户看到该座位是可售状态,但锁座会一直失败,虽然座位已售不会带来少卖的情况,但有损用户的体验;
  • 释放库存如果删除缓存成功但删除DB失败,也会出现座位可售,但用户锁座一直失败,这种情况会导致少卖;

如何解决一致性的问题?

  • 释放库存的不一致会导致少卖,所以需要保障最终一致性,而触发释放库存的逻辑只会发生在用户主动解锁或者用户超过15min未支付的场景,所以我们新增一个定时任务去扫描DB中超过15min库存状态为预占的流水信息,并删除缓存和DB记录,来保障缓存和DB的数据最终一致性。
  • 预占库存不会导致少卖,但影响用户体验,我们可以在用户体验上做优化,用户锁座失败的主要原因是DB唯一索引导致无法写入,可以拦截DuplicateKeyException异常,并触发一次删除预占库存缓存就能解决上面的问题,给用户的体验是锁座失败,但刷新座位图后该座位已变成已售状态,用户会认为座位被其他人抢了。

5.3.总结

方案一的整体架构简单清晰,通过set结构解决原子操作的问题,通过定时任务解决一致性问题,但查询座位图在大流量下有性能问题,电影节秒杀流量最大的入口就是查询座位图,采用smember这种O(n)的操作无法抗住20wQPS的压力,那么如何设计座位图的缓存?

6.设计方案二:过渡版

方案一的主要问题是查询缓存有性能问题,方案二优化了缓存结构,采用String的格式存储已售库存和预占库存,使查询变为O(1)操作,解决了查询座位图的性能问题,但也带来高并发下原子性操作的问题,下面详细介绍下方案二。

 6.1.业务逻辑

  • 查询库存:   redis采用String结构存储场次所有的已售库存和预占库存,缓存内容是seatId集合以逗号分隔,通过get指令所有已售+预售库存信息,再结合静态座位图信息可获取场次座位图的实时库存信息。
  • 预占库存:通过(场次ID+seatId)分布式锁来保障一个座位只有一个用户预占,锁座成功后插入DB库存流水表状态为预占,然后发送座位信息到MQ,异步任务更新库存缓存信息,为什么不能直接更新缓存?因为缓存是个String,一次更新需要经过get→内存修改→set3个步骤,我们无法保障3个步骤原子性操作,所以只能异步通过锁机制来保障原子性。
  • 核销库存:修改DB库存流水的状态为已售,同时修改DB库存主表的已售库存数,缓存中已经有该座位信息,所以不需要写缓存。
  • 释放库存:先删除DB库存流水,然后异步发送座位信息到MQ,异步更新库存缓存。

6.2.技术方案

  • 高并发原子性:  通过修改缓存结构,解决了大流量查询的性能问题,引入Queue异步修改缓存解决原子性操作问题,异步修改缓存有2种类型,分别是新增和删除预占、已售库存,但异步操作也会带来实时性和时序性问题。

  • 实时性:异步更新缓存如何保障秒杀场景下的够实性,每次异步更新座位图需要对缓存进行setNX→get→set→del 4次操作,假如按10ms来计算,一个普通场次有200个座位,在秒杀最极端的情况所有座位图同时被锁,更新的耗时是200*10ms=2s,用户第一次抢座失败后刷新座位图2秒内能看到最新的座位图,所以可以通过交互做一些优化,避免用户因为座位图更新不实时导致多次锁座失败的场景出现。

  • 时序性:Queue会存在时序性问题,主要发生在预占库存和释放库存之间,用户锁座后马上取消锁座,此时会同时向Queue发送add和del同一个seatId缓存事件操作,由于时间间隔短,而无法保证那个事件先到Queue,所以会出现下面2种情况:

    1. 先add再del,正常流程,对业务没有影响。
    2. 先del再add,对业务有影响,缓存有数据但DB实际未售,会导致业务少卖。

    第2种情况下的少卖,也是DB和缓存数据一致性的问题。

  • 一致性:除了Queue时序性带来的一致性问题外,还有消费Queue或者发送Queue失败都可能导致缓存与DB数据不一致,如何解决这个问题?可以采用定时任务去保障最终一致性,针对每个场次信息定时检查DB和缓存数据是否一致。

6.3.总结

方案二相比方案一解决了查询的性能问题,但系统的复杂性增加了很多,尤其是Queue的稳定性和时序性带来的不一致问题,需要引入所有场次定时做缓存与DB的比对任务,且任务在数据比对过程中需要对场次加锁,而消费Queue的流程也需要对场次加锁来更新缓存信息,这导致同一个锁会分散在2个流程或发布项中,加大了系统后期的运维成本和复杂性,那么有没有办法解决这个问题?

7.设计方案三:最终版

方案二的不一致性只会出现DB数据准确但缓存数据不准确的情况,只要保证缓存数据能做到最终一致性就能解决问题,缓存的操作包括新增和删除座位信息,新增操作没有写入缓存只会影响用户体验,不会出现少卖(参考方案一),所以关键的是解决删除缓存的不一致,也就是缓存可以比BD少数据但不能多数据,而删库存缓存只会出现在下面2个业务场景:

  • 退票业务:客服或用户操作退票,BD座位图状态从已售 → 未售,缓存删除该座位图信息失败;

  • 解锁业务:用户锁座后主动解锁以及超过15min未支付系统自动解锁,BD座位图状态从预占 → 未售,缓存删除该座位图信息失败;

退票业务在北影节发生概率相对小,且交易的退票业务有任务轮训,所以可以由上游交易业务保障释放库存操作的最终一致性,在释放库存时保证缓存的操作是同步的,操作缓存失败返回退票失败,由上游系统做重试保证数据最终一致性。

解锁是频繁的业务,如果DB删除而缓存没删会导致少卖,用定时任务扫表比对的方案又导致系统过于复杂,有没有可能让缓存定时失效,来达到数据最终一致性的效果。

我们将缓存中库存信息拆分成已售库存和预占库存2个key,预占库存缓存设置了失效时间,新增和删除缓存的已售库存都是同步操作BD和缓存,依赖上游交易出票和退票业务的同步机制来保障最终一致性,预占库存缓存的添加和删除依赖消费Queue异步操作,当出现数据不一致,类似预占库存缓存多了数据,会有缓存失效来保障最终的一致性,避免出现上面的少卖的情况,但预占库存缓存存的是该场次的所有预占库存信息,如果失效会导致所有预占态库存都失效,也就会影响到座位图的准确性。

某场次8点开启秒杀,但场次预占库存缓存的失效时间是8点05秒,8点05锁座成功的座位在出票中,此时缓存失效了,导致后面进来的用户看到该座位一直是未售状态,但一直锁座不成功,只有等出票完成后才看到座位图变成已售状态,严重影响了座位图的实时和准确性。

针对这种情况系统对预占缓存引入了续签操作,每次有锁座请求都会将预占缓存的失效时间重新设为N分钟,这保障了座位图在流量高峰区的实时性和准确性,但预占库存缓存的不一致还是会出现在某些极端情况下。

  • 当预占缓存设置N分钟失效,某个用户在开场前N分钟内购票后解锁,此时系统出现一致性问题,缓存在场次放映后才失效,就会导致这个座位无法售卖。
  • 在低峰期,如果用户A预占了某个库存,N分钟后预占库存的缓存失效了,用户B就会看到这个座位是未售,但B锁座会失败,因为数据库这个座位是预占状态,这种情况可以通过捕抓DuplicateKeyException更新缓存数据来优化。

第一种场景的不一致需要我们合理的设置缓存失效时间,猫眼预占库存最长时间是15min,那么如何设置失效时间N?

  • 针对开场前N分钟会出现少卖的场景,想要影响越小,N设置越小越好;
  • 如果N设置太小会频繁出现用户看到座位可售但实际无法锁座的场景,伤害用户体验;

需要在2者之间权衡,最终我们参考猫眼用户的平均支付时长做设置,N设置为5min。

7.1.业务逻辑

  • 查询库存: redis采用String结构存储,通过场次ID从缓存中分别获取已售库存和预占库存信息,结合静态座位图信息可获取场次座位图库存的全部信息。

  • 预占库存:通过(场次ID+座位ID)分布式锁来保障一个座位只有一个用户预占,锁座成功后插入库存流水表座位状态为预占,如果出现DuplicateKeyException异常这发送MQ更新缓存预占库存,无异常发送MQ更新缓存预占库存。

  • 核销库存:获取已售场次锁,操作缓存增加已售库存,修改数据库库存流水的状态为已售,同时修改库存主表的已售库存数,由上游交易出票业务重试保证数据最终一致性。
  • 释放已售库存:先删除分布式锁,然后同步删除缓存已售库存,再删除数据库的库存流水,由上游交易退票业务重试达到数据最终一致性。

  • MQ异步预占库存或释放预占库存:从Queue中接收库存事件,获取预占场次锁,操作缓存新增或删除预占库存,释放锁,每次异步更新座位图需要对缓存进行setNX→get→set→del 4次操作,假如按10ms来计算,一个普通场次有200个座位,在秒杀最极端的情况所有座位图同时被锁,更新的耗时是200*10ms=2s,如果第201个用户锁座失败,用户2s后刷新座位图后能够马上看到该场次所有座位售罄的状态。

7.2.技术方案

  • 高并发原子性:  需要2次redis查询,对20W的QPS压力不大,原子性操作通过异步MQ解决。
  • 实时性:异步更新座位图需要对缓存setNX→get→set→del 4次操作,假如按10ms来计算,一个普通场次有200个座位,在秒杀最极端的情况下,所有座位图同时被锁的耗时是200*10ms=2s,结合用户体验的优化可以接受。
  • 时序性:通过预占库存和已售库存缓存分离,预占缓存自动失效机制解决了时序错乱带来的少卖问题。
  • 一致性:DB和缓存可以做到最终一致性。

7.3.总结

方案三相对于方案二的架构更加简单清晰,已售和预占缓存分开管理,将变化比较大预占库存缓存交给MQ处理,变化较小的已售库存缓存结合上下游同步修改,降低了后期维护成本。

8.压测验证

8.1.压测目标

接口压测流量(QPS)
查询库存20w
预占库存10w
核销库存2000
释放库存1000

8.2.压测结果

符合预期

8.3.压测问题

1. 线程blocked导致大量流量拒绝异常

  • 现象:第一次独立压测,预占库存流量达到2w-qps时,发生大量流量拒绝异常;
  • 排查分析:通过查看监控信息,发现thrift线程池被打满;随后查看堆栈dump信息时,发现线程池中线程状体全部为blocked状态,并且堆栈信息显示和日志解析相关。
  • 问题原因:基于上述问题排查结果,最终定位到问题原因。问题的根本原因是因为jvm对反射的优化导致的。当日志中包含反射相关类的堆栈信息的时候,对这些类的类加载操作就会被jvm错误的优化为以同步加锁方式的进行类加载。由于我们的日志方式采用的是aop拦截在接口入口层进行打点,导致在秒杀流量进入的时候,所有线程都需要去获取类加载的同步锁,导致线程被blocked,从而发生大量流量拒绝异常。详细分析可见这篇博客:一个关于log4j2的高并发问题 - Fliaping's Blog
  • 解决方案:对aop中info日志增加开关,在秒杀场景下关闭aop中info日志开关。

2. 线程池预热问题

  • 现象:在全链路压测过程中,发现第一波压测流量出现大量流量拒绝异常和缓存连接超时异常
  • 问题分析:经排查分析,发现问题可能和线程池创建线程方式有关,这里简单概括线程池创建线程过程:在线程池创建之初,线程池中没有线程,随着流量进入,线程池开始创建线程直到达到核心线程数量;当没有核心线程中没有空心的线程切流量继续进入的时候会继续创建线程直至最大线程数量,当达到最大线程数。
  • 问题原因:第一波压测流量进入,thrift线程池和缓存连接池需要一边接收请求,一边创建线程,但是创建线程速度跟不上流量进入速度,导致发生流量拒绝异常和连接超时异常。第二波流量进入时,由于第一波流量已经完成了线程创建,且还没有超过非核心线程的生存时间,不需要临时创建线程,所以没有发生流量拒绝异常和连接超时问题。
  • 解决方案:调整核心线程数等于最大线程数,并在服务重启后开售前,起一波查询压测流量,实现对线程池的预热。

 

3. 全链路下单失败问题

  • 现象:在第一次全链路压测过程中,发现部分下单失败的请求打点,并且交易服务捕获到库存查询异常。
  • 问题分析:经过交易服务排查,发现捕获的异常错误码为库存业务异常错误码,含义是没有查询到库存记录,导致下单时未查询到库存记录而下单失败。
  • 问题原因:由于在预占库存阶段,库存会将库存记录入库(主库),同时在交易下单阶段,交易服务会来查询库存记录(从库),由于两次操作时间接近(小于1s),DB存在主从延迟导致库存记录查询失败,上游交易业务因此下单失败。
  • 解决方案:在预占阶段,库存记录写入数据库后,同步写入缓存;在下单查询库存记录阶段,先查缓存,若缓存中没有再去查库。

9.结果

       北京电影节开售日,库存出色的保障了北影的稳定售票,下图为北影节售票日流量于春节流量的对比图,北影节的流量比春节流量要高,锁座流量为春节的10倍,核销流量为春节4倍,数据表明我们的选座库存系统具备承担线上秒杀抢票的能力

  • 动态座位图的平均更新时间为:6ms/座位

 

  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值