跳至主要內容

【优惠券系统】设计核心

holic-x...大约 23 分钟架构系统设计

【优惠券系统】设计核心

学习核心

  • 如何设计一个【优惠券】系统?(系统的核心功能)

  • 沟通对齐

    • ① 需求分析:基于toBtoC两个角度出发,简单来说,对于商户而言是发券(券模板管理),对于用户来说是领券(券发放记录管理)
    • ② 请求量分析:10w+ 量级
    • ③ 精准度分析:此处的精准度指的是用户点击领券之后券库存和领券记录这两个数据的一致性(用户一次只能领取一张优惠券,商家不能少发或超发)
    • ④ 难点分析
  • 整体设计

    • ① 服务设计(分层设计):基于toBtoC两个角度出发设计核心服务
    • ② 存储设计(存储选型):
      • MySQL存储核心数据(券模板信息、券发放记录)
      • Redis 缓存辅助数据读写操作(例如读券模板信息、券模板库存扣减等)
      • 中间件:借助MQ中间件辅助缓冲服务压力,解决幂等问题
    • ③ 业务设计(业务流程):基于核心流程构建完整服务
      • toB:商家 进入【优惠券发布页面】-【填写优惠券信息(名称、类型、面额、使用条件、有效期、数量等)】-【点击 发布】(后台新增一条"券模板"记录)
      • toC:用户 通过一些营销渠道(例如刷直播发现某个店铺有优惠券可以领),点击【领券】,经过后台验证之后用户成功领券(后台新增一条"券发放记录",相应"券模板"库存减1)
  • 要点分析(或难点分析)

    • ① 核心流程:发券流程
    • ② 高并发、大流量场景问题和解决方案
      • 存储瓶颈:MySQL单机存储瓶颈,采用分治思路,引入读写分离、分库分表策略缓冲数据库DB访问压力
      • 热点库存:Redis单片访问压力(例如一个券模板放在一个Redis分片中),将主库存(key)打散成多个子库存(key1、key2...keyN),采用随机轮询的方式降低Redis的热点库存的访问压力
      • 券模板获取失败:可以基于内部重试或者引入二级缓存(推荐)的方式
    • ③ 服务治理
      • 超时设置、监控报警、限流、资源隔离
    • ④ 系统压测
      • 瓶颈分析、压测资源预估、压测过程分析、压测数据收集、预留扩展资源
  • 总结陈述

    • 深刻总结
      • 从基础的发券、验券功能切入,构建了一个基础的优惠券系统架构,从服务设计、存储选型、业务流程等方面分析了优惠券系统的一些设计核心和要点
    • 要点牵引
      • 核心流程、高并发/大流量场景把控:对于一个优惠券系统而言,除却关注核心的发券流程,还需要把控在高并发、大流量场景下的衍生问题和相应的解决方案,例如此处在针对存储瓶颈(采用分治思想解决:读写分离、分库分表等)、热点库存(子库存拆分)、券模板获取失败(引入二级缓存)等问题分析中梳理相应的解决方案
      • 服务治理:其次,优惠券系统作为一个服务供上游调用,因此要通过一系列操作保障系统的可靠运行(超时设置、监控报警、限流、资源隔离等机制)
      • 压测:要上线一个新服务,压测是必不可少的环节,压测中有很多微妙的细节需要细细体会
    • 收尾请教
      • 以上便是对【优惠券系统】的系统设计,如有不足还请多多指教

学习资料

🟢【优惠券系统】场景核心

​ 在一些促销活动场景中,各个业务方都有发放优惠券的需求,且对发券的 QPS 量级有明确的需求。所有的优惠券发放、核销、查询都需要一个新系统来承载。因此,需要设计、开发一个能够支持十万级 QPS 的优惠券系统,并且对优惠券完整的生命周期进行维护

🚀【优惠券系统】场景实战

1.沟通对齐

​ 此处的沟通对齐方向,主核心方向是需求分析、请求量分析、精准度分析、难点/要点分析,可能还有涉及到其他的一些容量、设计等方面的对齐

① 需求分析

优惠券系统核心

​ 优惠券系统涉及到几个重要的点:配置卷、发卷、使用卷(在下单的时候使用优惠卷)

  • ① 配置卷(券模板相关):涉及到卷批次(卷模板)的创建,需要存储卷模板的基础配置、有效期和券的库存信息
  • ② 发卷(券纪录相关):涉及到卷记录的创建和管理(过期时间、状态),例如券的核销(在用户下单的时候进行使用(一般是结合购物车结算的时候进行校验扣减))

​ 无论是针对卷模板还是卷记录,都需要开放查询接口,支持卷模板/卷记录的查询

业务流程分析

​ 基于上述要点分析,构建一个基础的发卷系统的业务流程

  • 券模板:管理平台或者商铺发放的优惠券(toB)
  • 券记录:管理客户的领券记录(toC)

② 请求量分析

​ 目标是设计、开发一个可以支持10w级QPS的优惠券系统

③ 精准度分析

④ 难点/要点分析

2.整体设计(架构设计)

① 服务设计(分层设计)

​ 此处将券系统分为toBtoC两大模块:

  • toB(券模板管理):创建券模板、查询券模板列表、券模板状态管理 .... 等核心功能
  • toC(券记录管理):发放券、核销券、券状态管理、查询券列表.... 等核心功能

② 存储设计(存储选型)

​ 确定了基本的需求,进一步分析可能会用到的中间件以及系统整体的组织方式

  • 存储:券模板、券记录这些都是需要持久化的数据,需要支持条件查询,因此选用通用的结构化存储MySQL作为存储中间件
  • 缓存:考虑发券和券的库存管理操作是一个高频操作,因此引入主流的Redis缓存缓解访问压力
    • 发券:发券时需要模板信息,大流量情况下不可能每次都从MySQL获取券模板信息,因此考虑引入缓存
    • 券的库存管理/库存扣减:券的库存构建也是一个高频、实施操作,借助缓存辅助(先在缓存中扣减,定期向数据库一次性更新扣减后的库存
  • 消息队列:选用 RocketMQ 支持延时消息,根据券模板/券记录的不同状态进行业务逻辑处理
    • 不同状态处理:由于券模板/券记录都需要展示过期状态,并且根据不同的状态进行业务逻辑处理,因此有必要引入延迟消息队列来对券模板/券状态进行处理

数据结构ER图

image-20250124135225606

​ 简化版本对照说明如下:

③ 业务设计(业务流程)

系统框架(系统定位):发券系统作为下游服务,是需要被上游服务所调用的。公司内部服务之间,采用的都是 RPC 服务调用,因此可以选用spring 系列框架 + MySQL+Redis+RocketMQ 来实现发券系统,RPC 服务部署在公司的 docker 容器中

image-20250124133236427

  • toB:商家 进入【优惠券发布页面】-【填写优惠券信息(名称、类型、面额、使用条件、有效期、数量等)】-【点击 发布】(后台新增一条"券模板"记录)
  • toC:用户 通过一些营销渠道(例如刷直播发现某个店铺有优惠券可以领),点击【领券】,经过后台验证之后用户成功领券(后台新增一条"券发放记录",相应"券模板"库存减1)

3.要点分析(核心逻辑实现扩展说明)

① 发券流程

发券核心流程:参数校验、幂等校验、库存扣减

image-20250124142629887

如何理解幂等性问题?

​ 幂等操作:用于保证发券请求不正确的情况下,业务方通过重试、补偿的方式再次请求,最终只发出一张券,防止资金损失。而此处的幂等操作可以理解为一个接口被调用一次或多次返回的结果是相同的。通俗一点的解释就是,上述的发券操作过程中涉及到两个重要的操作:【减少券模板库存】、【新增券发放记录】,这两个操作是一个整体的操作,幂等的体现在于无论用户点击多少次领取优惠券按钮,最终的结果都是用户只能领取到1张优惠券、库存数量减1,而不能出现连续重复点击就重复发放的钻空子黄牛行为,或者出现是由于网络抖动导致用户点击了一次但是传递了两个请求到服务器引发的重复发放问题

如何理解上述幂等问题中的重试补偿?

​ 基于上述分析,券模板库存减少和券发放记录新增这两个数据操作并不是一次完成的,而是顺序两次完成的,可能是先减少券库存后新增发放记录,也可能是先新增发放记录后减少券库存,不管是哪种情况都可能会因为服务器宕机导致数据不一致的情况(可以结合两种情况进行分析)

  • (1)先减少券库存后新增发放记录:

​ 基于上述步骤分析,如果在执行到第③步的时候服务器突然宕机,这个时候就会出现券库存虽然减少了1,但是实际上却没有匹配的用户领取记录。相当于对用户而言,明明领取了优惠券,库存也减少了,但是却跟踪不到自己的领券记录,因此需要补偿用户未领取的优惠券

  • (2)先新增发放记录后减少券库存:

​ 同理,基于上述步骤分析,如果执行到第③部的时候服务器突然宕机,这个时候用户领取了优惠券,也可以跟踪到自己的领券记录,但库存却没有减少,因此导致了"超发"问题,也就是上面提到的由于库存超发给平台或者商铺带来损失

​ 为了解决上述问题,可以通过引入MQ来处理,MQ在服务宕机重新启动时可以设置重新发送上次位置的消息。在存入消息到MQ之前,先在Redis中存入一个KV(用于加分布式锁和存储状态)

image-20250124154411345

② 券过期

​ 券过期是一个状态推进的过程,此处使用RocketMQ来实现,参考流程说明如下:

image-20250124143033288

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

③ 大流量、高并发场景下的问题和解决方案

​ 基于上述系统基本功能分析,进一步讨论一下如果在大流量、高并发场景下系统可能遇到的一些问题和解决方案:

(1)存储瓶颈及解决方案

瓶颈分析

在系统架构中,使用了 MySQL、Redis 作为存储组件。但单个服务器的 I/O 能力终是有限的,在实际测试过程中(由字节业务团队技术侧分析评估),能够得到如下的数据:

  • 单个 MySQL 的每秒写入在 4000 QPS 左右,超过这个数字,MySQL 的 I/O 时延会剧量增长
  • MySQL 单表记录到达了千万级别,查询效率会大大降低,如果过亿的话,数据查询会成为一个问题
  • Redis 单分片的写入瓶颈在 2w 左右,读瓶颈在 10w 左右

解决方案

  • ① 读写分离:在查询券模板信息、券记录等场景下,可以采用对MySQL进行读写分离的方式,让这部分查询流量走MySQL的读库,进而减轻MySQL写库的查询压力
  • ② 分治:基于软件设计的分治思想,对于存储瓶颈的问题,业界最常用的方案就是分而治之(流量分散、存储分散),即采用分库分表的思路
    • MySQL的分库分表
      • 发券:归根结底是要对用户的领券记录做持久化存储。基于MySQL本身的IO瓶颈,可以在不同的服务器上部署MySQL的不同分片,对MySQL做水平扩容。基于此,写请求就会分布在不同的MySQL主机上,以大幅提升MySQL整体的吞吐量
      • 查询券:如果需要进行分库分表操作,则需选择合适分片策略。此处基于【用户需要查询自己所获得的券】这个场景考虑,可以以用户ID(user_id)后四位作为分片键,对用户领取的记录表进行水平拆分,以支持用户维度的领券记录的查询操作
    • Redis 水平扩容
      • 每种券都有对应的数量,在给用户发券的过程中,是将发券数记录在 Redis 中的,大流量的情况下,也需要对 Redis 做水平扩容,减轻 Redis 单机的压力

容量预估

​ 基于上述思路,可以思考在要满足发券12w QPS 的需求下,可以预估一下存储资源

  • ① MySQL 资源:基于实际测试,单次发券对MySQL有一次非事务性写入,MySQL单机写入瓶颈为4000,因此预计所需MySQL主库资源为120000 / 4000 = 30
  • ② Redis 资源:假设12w的发券QPS都是基于同一个券模板,单分片的写入瓶颈为2w,那么所诉的最少Redis分片为:120000 / 20000 = 6
(2)热点库存及解决方案

问题分析

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

解决方案:将数据拆分打散,均匀分布到各个分片中

​ 从问题本身出发,为了解决热点库存问题(即热点key问题),最常见的方案就是打散(将数据均匀分布在不同分片上),业界也有通用的方案:即扣减的库存key不要集中在一个分片上,因此思考如何保证这一个券模板的key不集中在某一个分片上呢?=》同理,拆用分拆思路,拆 key 即可(拆库存)

① 建券:在创建券模板的时候做库存拆分

② 库存扣减:在后续扣减库存的时候也相应扣减子库存

image-20250124161038636

​ 结合上述图示分析,在做库存扣减操作的时候是依次校验各个子库存(例如此处每次都是从子库存1开始进行,然后依次校验....),相当于每次都先将流量打到了子库存1,实际上Redis分片压力并没有减轻,因此此处需要做到的是让每个分片库存都可能被随机轮询到,以减轻分片压力(最简单的一种方式就是自己手动指定随机规则,例如每次进行扣减子库存之前,先根据分片总数生成一个随机数组序列,根据生成的序号决定子库存的访问顺序,从而达到随机访问的目的)

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

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

补充思路:key 备份(基于备份的概念将数据打散,相当于每个分片都有相同的数据)

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

(3)券模板获取失败问题及解决方案

问题分析

​ 高 QPS,高并发的场景下,即使能将接口的成功率提升 0.01%,实际表现也是可观的。现在回过头来看下整个发券的流程:查券模板(Redis)-->校验-->幂等(MySQL)--> 发券(MySQL)。在查券模板信息时,会请求 Redis,这是强依赖,在实际的观测中会发现,Redis 超时的概率大概在万分之 2、3。因此,这部分发券请求是必然失败的。

解决方案

​ 为了提高这部分请求的成功率,有两种方案:

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

​ 内部重试可以提高一部分请求的成功率,但无法从根本上解决 Redis 存在超时的问题,同时重试的次数也和接口响应的时长成正比。二级缓存的引入,可以从根本上避免 Redis 超时造成的发券请求失败,因此考虑选用二级缓存方案,流程分析如下:

​ 当然,引入了本地缓存,还需要在每个服务实例中启动一个定时任务来将最新的券模板信息刷入到本地缓存和 Redis 中,将模板信息刷入 Redis 中时,要加分布式锁,防止多个实例同时写 Redis,给 Redis 造成不必要的压力

④ 服务治理

系统开发完成后,还需要通过一系列操作保障系统的可靠运行:

  • ① 超时设置:优惠券系统是一个 RPC 服务,因此需要设置合理的 RPC 超时时间,保证系统不会因为上游系统的故障而被拖垮
    • 例如发券的接口,内部执行时间不超过 100ms,因此接口超时可以设置为 500ms,如果有异常请求,在 500ms 后就会被拒绝,从而保障服务稳定的运行
  • ② 监控与报警:对于一些核心接口的监控、稳定性、重要数据,以及系统 CPU、内存等的监控
    • 例如在 Grafana 上建立对应的可视化图表,在春节活动期间,实时观测 Grafana 仪表盘,以保证能够最快观测到系统异常。同时,对于一些异常情况,建立还有完善的报警机制,从而能够第一时间感知到系统的异常
  • ③ 限流:优惠券系统是一个底层服务,实际业务场景下会被多个上游服务所调用
    • 合理的对这些上游服务进行限流,也是保证优惠券系统本身稳定性必不可少的一环
  • ④ 资源隔离:
    • 因为服务都是部署在 docker 集群中的,因此为了保证服务的高可用,服务部署的集群资源尽量分布在不同的物理区域上,以避免由集群导致的服务不可用

⑤ 系统压测 & 实际表现分析

系统压测

​ 在完成一个系统设计和开发之后,需要检测服务在实际生产环境中的表现,因此在上线前必须先对服务进行压测,此处需要关注一些问题:例如压测方向、压测资源、压测过程监控、压测数据分析等

  • ① 压测方向(压测思路):一般难以直接确定服务的瓶颈(例如docker、存储组件等),因此压测的方向从单机实例入手,逐步突破存储组件的读、写压力
    • 找到单实例的瓶颈
    • 找到MySQL一主的写瓶颈、读瓶颈
    • 找到Redis单分片的写瓶颈、读瓶颈
    • 得到上述数据之后,进一步粗略估算所需要的资源数,以进行服务整体的压测
  • ② 压测资源:确定好压测方向和资源估算之后,需要提前申请到足量的压测资源才能合理制定压测计划
  • ③ 压测过程监控:压测过程中,要注意服务和资源的监控,对不符合预期的部分要深入思考,优化代码
  • ④ 压测数据分析:适时记录压测数据,才能更好的复盘。

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

实际表现分析

​ 系统在 13w QPS 的发券请求下,请求成功率达到 99.9%以上,系统监控正常。春节红包雨期间,该优惠券系统承载了两次红包雨的全部流量,期间未出现异常,圆满完成了发放优惠券的任务

⑥ 业务思考

  • 目前的系统,只是单纯支持了高并发的发券功能,对于券的业务探索并不足够。后续需要结合业务,尝试批量发券(券包)、批量核销等功能
  • 发券系统只是一个最底层的业务中台,可以适配各种场景,后续可以探索支持更多业务

4.总结陈述

  • 深刻总结

    • 从基础的发券、验券功能切入,构建了一个基础的优惠券系统架构,从服务设计、存储选型、业务流程等方面分析了优惠券系统的一些设计核心和要点
  • 要点牵引

    • 核心流程、高并发/大流量场景把控:对于一个优惠券系统而言,除却关注核心的发券流程,还需要把控在高并发、大流量场景下的衍生问题和相应的解决方案,例如此处在针对存储瓶颈(采用分治思想解决:读写分离、分库分表等)、热点库存(子库存拆分)、券模板获取失败(引入二级缓存)等问题分析中梳理相应的解决方案
    • 服务治理:其次,优惠券系统作为一个服务供上游调用,因此要通过一系列操作保障系统的可靠运行(超时设置、监控报警、限流、资源隔离等机制)
    • 压测:要上线一个新服务,压测是必不可少的环节,压测中有很多微妙的细节需要细细体会
  • 收尾请教

    • 以上便是对【优惠券系统】的系统设计,如有不足还请多多指教

​ 从零搭建一个大流量、高并发的优惠券系统,首先应该充分理解业务需求,然后对需求进行拆解,根据拆解后的需求,合理选用各种中间件;此处主要是要建设一套优惠券系统,因此会使用各类存储组件和消息队列,来完成优惠券的存储、查询、过期操作;

​ 在系统开发实现过程中,对核心的发券、券过期实现流程进行了阐述,并针对大流量、高并发场景下可能遇到的存储瓶颈、热点库存、券模板缓存获取超时的问题提出了对应的解决方案。其中,使用了分治的思想,对存储中间件进行水平扩容以解决存储瓶颈;采取库存拆分子库存思路解决热点库存问题;引入本地缓存解决券模板从 Redis 获取超时的问题。最终保证了优惠券系统在大流量高并发的情景下稳定可用;

​ 除开服务本身,还从服务超时设置、监控报警、限流、资源隔离等方面对服务进行了治理,保障服务的高可用;

​ 压测是一个新服务不可避免的一个环节,通过压测我们能够对服务的整体情况有个明确的了解,并且压测期间暴露的问题也会是线上可能遇到的,通过压测,能够对新服务的整体情况做到心里有数,对服务上线正式投产就更有信心了

🚀实战案例

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3