本期作者
1.背景
会员购是B站2017年推出的IP消费体验服务平台,在售商品以手办、漫画、JK制服等贴合平台生态的商品为主。随着业务发展,会员购从最开始的预售,现货拓展到全款预售,盲盒,众筹等多种售卖方式,销售渠道也遍布 猫耳(现已下线),QQ小程序,漫画等多个业务渠道,再加上不断增加的营销活动玩法,每年几次大促活动的爆发式流量,对于会员购交易系统来说,无疑是一个巨大的挑战。
2.性能
每年的拜年纪,626(公司周年庆),919(会员购周年庆),会员购都会搞大促活动,运营会挑选一些比较热门的手办进行首发,加上提前发放红包优惠券,各种优惠活动的刺激,每次大促0点开售流量就是几百倍的爆发,早期也因为压力太多出过几次事故,所以如何优化性能,提高交易的吞吐量是首要的。
2.1调用链路优化
面临问题:
在最初版的系统中,下单接口有明显的等待时间,用户体验不是很好,能支持的最大qps也有限
如图 2-1 所示 通看分析下单调用链路发现,存在多个接口重复调用,接口全是串行调用的情况,下单接口耗时太长,达到400+ms,已经严重影响系统性能及用户体验
图 2-1 初版下单链路图
从链路上可有看出下单是IO密集型应用,CPU 利用率低,代码串行执行的话同步等待时间较长,为止我们重新梳理下单业务逻辑,对下单流程进行责任链模式改造,如2-2图所示
2-2 下单链路简单示意图
同时我们对系统做了以下优化
-
对没有依赖的服务进行并发调用(商品/店铺/活动/用户信息等一起并发调用),如图2-3所示
-
优化调用链减少冗余调用,推动下游服务接口改造及合并,保证一次请求下来,每个基础接口只会被调用一次,如图2-3所示
-
设置合理的超时时间和及连接重试(200ms, 部分接口99分位上浮100%,connect连接重试)
-
排除事务内的外部调用(服务依赖,mq,缓存)
-
对弱依赖接口进行mq或异步调用(设置关注/缓存手机号/回滚库存优惠券等)
2-3 优化后的调用链路
经过优化后的接口耗时如2-4图所示,从原来300ms降低100ms左右,效果比较显著,用户的下单体验得到较大提升
2-4 下单耗时对比图
2.2异步下单优化
面临问题:电商活动离不开秒杀场景,通常情况下小库存秒杀做好限流的话问题不大,但拜年祭手办通常有5000个左右的库存,如2-5如图所示,属于大库存秒杀 , 限流值设得太小会严重影响用户体验 ,大库存抢购时下单qps遇到瓶颈 600+qps的时候库存服务行锁比较严重,耗时开始大幅上升,大量数据库操作占用连接数较高。
2-5 拜年纪商品
思考:服务器的处理能力是恒定的,就像早高峰一样,需要错峰限行,这就是我们说的削峰,对流量进行削峰不仅让服务器处理得更加平稳,也节省服务器资源。一般削峰的手段有验证码,排队等方式,这里我们主要是采用异步下单这种排队的做法。
说到排队,最容易想到的就是消息队列,可以通过消息队列把两个系统模块进行解耦,对于抢购场景来说也是非常合适的,可以有效把流量通过队列来承接,然后平滑得进行处理,如图2-6所示
2-6 消息队列解耦
按照消息队列的排队方案,我们把整个下单流程调整为异步批量下单链路(图2-7所示) ,在合法校验过后生成订单号提交到databus消息队列 (图2-8所示),再监听databus批量拉取订单进行合并下单(图2-9所示),目前设置的是最多20个一消费,下单结果会在数据库及redis中保存。
2-7异步下单链路
2-8 提交下单请求直mq
2-9 从mq消费订单消息
如图2-10所示,在进入队列后,前端会提示活动火爆,正在努力下单中 ,同时在0~2秒内随机调用下单结果查询接口,轮询30秒(必须设置最大时间兜底,防止无限查询)
对于合并的订单进行批量冻结库存,并行冻结优惠券,批量合并sql插入数据库,最大限度上减少性能消耗
2-10 异步下单示例图
其他优化细节
- 下单限频/限流
- 其中对于一些弱依赖的操作直接进行降级,比如设置商铺关注,缓存手机号,记录操作日志等
- 批量操作异常时(接口超时则fail fast),会分解为单个订单重新进行调用(库存操作会试探单库存扣减 单库存扣减成功 并发请求剩余订单,单库存扣减失败 剩余订单全部置为失败)
- 下单结果查询走redis,异常情况降级为数据库
- databus异常时,直接降级为同步下单(库存服务也会做限流)
- databus消费者会做幂等及超时判断(订单投递时间跟当前时间差值),超过一定时间会自动抛弃,下单失败
经过改造,压测下单支持4000+tps,最终也顺利利用异步下单支撑了早期的拜年祭手办抢购,如图2-11所示
2-11 活动抢购qps图
2.3分库分表
首先并不是所有表都需要进行切分,主要还是看数据的增长速度。切分后会在某种程度上提升业务的复杂度,避免"过度设计"和"过早优化"。分库分表之前,不要为分而分,先尽力去做力所能及的事情,例如:升级硬件、升级网络、垂直拆分、读写分离、索引优化等等。当数据量达到单表的瓶颈时候,再考虑分库分表。
数据量过大的风险如下:
1)高负载下主从延迟严重,影响用户体验,并且对数据库备份,如果单表太大,备份时需要大量的磁盘IO和网络IO。例如1T的数据,网络传输占50MB时候,需要20000秒才能传输完毕,整个过程的风险都是比较高的
2)对一个很大的表进行DDL修改时,MySQL会锁住全表,这个时间会很长,这段时间业务不能访问此表,影响很大。如果使用pt-online-schema-change,使用过程中会创建触发器和影子表,也需要很长的时间。在此操作过程中,都算为风险时间。将数据表拆分,总量减少,有助于降低这个风险。
3)大表会经常访问与更新,就更有可能出现锁等待,一旦出现慢查询,风险很大,容错性很低。将数据切分,用空间换时间,变相降低访问压力,而且利用水平切分,当一个数据库出现问题时,不会影响到100%的用户,每个库只承担业务的一部分数据,这样整体的可用性就能提高
这里我们明确下分库 分表到底能解决什么问题
-
分表:解决单表过大导致的查询效率下降(海量存储,即使索引正确也会很慢) MySQL 为了提高性能,会将表的索引装载到内存中。InnoDB buffer size 足够的情况下,其能完成全加载进内存,查询不会有问题。但是,当单表数据库到达某个量级的上限时,导致内存无法存储其索引,使得之后的 SQL 查询会产生磁盘 IO,从而导致性能下降。当然,这个还有具体的表结构的设计有关,最终导致的问题都是内存限制。这里,增加硬件配置,可能会带来立竿见影的性能提升。
-
分库:解决Master服务器无法承受读写操作压力(高并发访问,吞吐量)
在2020年的时候,会员购随着业务发展,订单数据快速增长,基本每半年数据量就会翻倍,所有核心表均达到千万级别 大表的DDL,查询效率,健壮性都有问题 ,并且高负载下,会有较为明显的主从延迟,影响到用户体验。
首先是技术选型:
站在巨人的肩膀上能省力很多,目前分库分表已经有一些较为成熟的开源解决方案:
- 阿里的TDDL,DRDS和cobar
- 开源社区的sharding-jdbc(3.x开始已经更名为sharding-sphere)
- 民间组织的MyCAT
- 360的Atlas
- 美团的zebra
这么多的分库分表中间件全部可以归结为两大类型:CLIENT模式 PROXY模式
无论是CLIENT模式,还是PROXY模式。几个核心的步骤是一样的:SQL解析,重写,路由,执行,结果归并。
经过讨论大家更倾向于CLIENT模式,架构简单,性能损耗较小,运维成本低,而且目前部分项目中都已经被引入shardingjdbc,并且部分模块已经在使用其分库分表功能,网上文档丰富,框架比较成熟 。
选择sharding key:
sharding column的选取是很重要的,sharding column选择的好坏将直接决定整个分库分表方案最终是否成功。
sharding column的选取跟业务强相关,选择sharding column的方法最主要分析你的API流量,优先考虑流量大的API,将流量比较大的API对应的SQL提取出来,将这些SQL共同的条件作为sharding column 例如一般的OLTP系统都是对用户提供服务,这些API对应的SQL都有条件用户ID,那么,用户ID就是非常好的sharding column。
非sharding column查询该怎么办?
-
建立非sharding column属性到sharding column的映射关系
-
双写冗余全量数据(不需要二次查询)
-
数据异构(TIDB,ES,HIVE等,应对复杂条件查询,近实时或离线查询)
-
基因融合(比如订单号里融合mid基因,最新的订单号规则:orderId+mid%512 比如4004164057659338)
切分策略:
1.范围切分
比如按照时间区间或ID区间来切分,如图3-1所示,优点:单表大小可控,天然水平扩展。缺点:无法解决集中写入瓶颈的问题。
3-1 范围切分
2.Hash切分
如图3-2所示,如果希望一劳永逸或者是易于水平扩展的,还是推荐采用mod 2^n这种一致性Hash
3-2 Hash切分
3.会员购交易切分策略
如图3-3所示,切分键选择:mid 和 order_id相关
根据 mid 分表,使用新的orderid 生成规则,orderid 融入(mid%512)
库表数量:4个集群(主从),每个集群4个库,每个库16张表,总计256张表
库路由策略:mid %16
表路由策略:(mid%512)/32
公式:
中间变量 = MID % (库数量*表数量)
库路由 = 中间变量 % 库数量
表路由 = 取整(中间变量 /库数量)
3-3 库表策略示意图
我们采用的是不清洗老数据的方式,好处是老的订单数据依然走老库,这样能节省一部分清洗数据的工作量
梳理sql:
项目中的sql有些是不满足分片条件的,所以我们是要提前梳理项目中的sql的
1.通过 druid 界面 可以统计到所有运行的 sql
2.配合静态扫描sql工具
3.DBA 拉取 SQL
4.人工查看代码 当梳理出对应所有 SQL
针对没有分片键的SQL进行改造,不确定的SQL进行验证,不支持的SQL给出处理方式
当然如何进行迁移也是很重要的步骤,我们是采用下面的步骤,如图3-4所示
-
历史数据归档,不做迁移,老数据修改依旧路由到老库
-
切读写请求,即将读写流量请求引入到新系统中
-
回写数据,binlog 监听新数据库回写到老系统中,并进行校验
3-4 不停机迁移示意图
最后总结下整个分库分表的步骤
1.根据容量(当前容量和增长量)评估分库分表个数
2.选key(均匀)
3.分表规则(hash或range等)
4.梳理sql并验证
5.执行(一般双写)
在整个交易系统完成分库分表后,彻底解决了数据库的瓶颈问题,历经多次大促压测突发流量等场景都没有出问题,保障了整个平台系统的稳定性。
3.总结
在经过调研链路优化,异步下单改造,数据库分库分表后,整个交易系统的性能得到了较大的提升,也较为顺利得支撑了历次大促活动,后续我们也会继续对一些历史系统(比如票务系统)进行改造升级来提升用户体验。