需求背景

春节活动中,多个业务方都有发放优惠券的需求,且对发券的 QPS 量级有明确的需求。所有的优惠券发放、核销、查询都需要一个新系统来承载。同时,需要对优惠券完整的生命周期进行维护。

需求拆解

从厂家配置的优惠券角度,创建优惠券(券模版)、有效期、库存数目、状态管理等。

从用户手中的优惠券角度,优惠券的创建、管理(过期时间、状态)。

系统选型

存储 MySQL、缓存 Redis。

由于券模板/券记录都需要展示过期状态,并且根据不同的状态进行业务逻辑处理,同时 RocketMQ 支持延时消息,引入 RocketMQ。

内部服务 golang + RPC 选择 kitex 框架。

系统架构设计

系统整体架构

640

数据结构 ER 图

300

核心逻辑

发优惠券分为三个部分:参数校验、幂等校验、库存扣减(生成订单数据 + 库存处理 + DB 记录)。

券过期:由于 RocketMQ 支持的延时消息有最大限制,而卡券的有效期不固定,有可能会超过限制,所以我们将卡券过期消息循环处理,直到卡券过期。

大流量&高并发的解决方案

存储瓶颈问题

瓶颈:

  • MySQL I/O 能力有限
  • MySQL 单表数据多,查询效率低
  • Redis 单分片 I/O 能力有限

解决:DB 读写分离、分治。

  • 对 DB 进行分库分表,发券时将写请求发送到不同的 DB 分片中。
  • 用户需要查询优惠券,因此分片采用 user_id 后四位为分片键,对用户领取的记录表做水平拆分。
  • 给用户发券过程中,发券记录在 Redis 中,大流量对 Redis 水平扩容。

容量预估:

  • 在要满足发券 12w QPS 的需求下,预估资源。
  • MySQL 资源:在实际测试中,单次发券对 MySQL 有一次非事务性写入,MySQL 的单机的写入瓶颈为 4000,因此需要主库 12w / 4000 = 30.
  • Redis 资源:假设 12w 的发券 QPS,均为同一券模板,单分片的写入瓶颈为 2w,则需要的最少 Redis 分片为 12w / 2w = 6.

热点库存问题

问题:如果使用的券模板为一个,那么每次扣减库存时,访问到的 Redis 必然是特定的一个分片,因此,一定会达到这个分片的写入瓶颈,更严重的,可能会导致整个 Redis 集群不可用。

解决:扣减的库存 key 不要集中在某一个分片上。如何保证这一个券模板的 key 不集中在某一个分片上呢,拆 key 即可。即,将热点券模版拆分成多个库存,后续扣减库存时,扣减相应的子库存,如每个分片上 1w 个优惠券,10 个分片 10w 优惠券。

建券过程:

640

库存扣减:

扣减子库存,每次都是从 idx=1 的分片开始进行的话,那对 Redis 对应分片的压力其实并没有减轻,因此,我们需要做到:每次请求,随机不重复的轮询子库存。以下是本项目采取的一个具体思路:

Redis 子库存的 key 的最后一位是分片的编号,如:xxx_stock_key1、xxx_stock_key2……,在扣减子库存时,先生成对应分片总数的随机不重复数组,如第一次是[1,2,3],第二次可能是[3,1,2],这样,每次扣减子库存的请求,就会分布到不同的 Redis 分片上,缓轻 Redis 单分片压力的同时,也能支持更高 QPS 的扣减请求。

这种思路的一个问题是,当我们库存接近耗尽的情况下,很多分片子库存的轮询将变得毫无意义,因此我们可以在每次请求的时候,将子库存的剩余量记录下来,当某一个券模板的子库存耗尽后,随机不重复的轮询操作直接跳过这个子库存分片,这样能够优化系统在库存即将耗尽情况下的响应速度。

业界针对 Redis 热点 key 的处理,除了分 key 以外,还有一种 key 备份的思路:即,将相同的 key,用某种策略备份到不同的 Redis 分片上去,这样就能将热点打散。这种思路适用于那种读多写少的场景,不适合应对发券这种大流量写的场景。在面对具体的业务场景时,我们需要根据业务需求,选用恰当的方案来解决问题。

券模版获取失败问题

问题:虽然每个环节成功率都很高,但是整个发券链路总体观测得出,Redis 超时概率在万分之 2-3。发券链路为:查询券模版 Redis,校验,幂等 MySQL,记录发券 MySQL。

两种解决方案

  1. 从 Redis 获取券模板失败时,内部进行重试
  2. 将券模板信息缓存到实例的本地内存中,即引入二级缓存

第二种方案更好,同时为了保持一致,引入本地缓存时,也需要在服务实例中启动定时任务将最新券模版信息刷入本地缓存 + Redis 中。其中刷入 Redis 需要分布式锁,避免多个实例同时写 Redis 造成压力。

服务治理

  1. 超时设置。优惠券系统是一个 RPC 服务,因此我们需要设置合理的 RPC 超时时间,保证系统不会因为上游系统的故障而被拖垮。例如发券的接口,我们内部执行时间不超过 100ms,因此接口超时我们可以设置为 500ms,如果有异常请求,在 500ms 后,就会被拒绝,从而保障我们服务稳定的运行。

  2. 监控与报警。对于一些核心接口的监控、稳定性、重要数据,以及系统 CPU、内存等的监控,我们会在 Grafana 上建立对应的可视化图表,在春节活动期间,实时观测 Grafana 仪表盘,以保证能够最快观测到系统异常。同时,对于一些异常情况,我们还有完善的报警机制,从而能够第一时间感知到系统的异常。

  3. 限流。优惠券系统是一个底层服务,实际业务场景下会被多个上游服务所调用,因此,合理的对这些上游服务进行限流,也是保证优惠券系统本身稳定性必不可少的一环。

  4. 资源隔离。因为我们服务都是部署在 docker 集群中的,因此为了保证服务的高可用,服务部署的集群资源尽量分布在不同的物理区域上,以避免由集群导致的服务不可用。

系统压测

  1. 首先是压测思路,由于我们一开始无法确定 docker 的瓶颈、存储组件的瓶颈等。所以我们的压测思路一般是:

    • 找到单实例瓶颈
    • 找到 MySQL 一主的写瓶颈、读瓶颈
    • 找到 Redis 单分片写瓶颈、读瓶颈

    得到了上述数据后,我们就可以粗略估算所需要的资源数,进行服务整体的压测了。

  2. 压测资源也很重要,提前申请到足量的压测资源,才能合理制定压测计划。

  3. 压测过程中,要注意服务和资源的监控,对不符合预期的部分要深入思考,优化代码。

  4. 适时记录压测数据,才能更好的复盘。

  5. 实际的使用资源,一般是压测数据的 1.5 倍,我们需要保证线上有部分资源冗余以应对突发的流量增长。

参考链接

实战! 如何从零搭建10万级 QPS 大流量、高并发优惠券系统- 字节跳动技术团队-2022