支付系统:优惠券

Page content

需求背景

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

需求拆解

  • 要配置活动,一个活动下面可以有多个券。
  • 要配置券,会涉及到券批次(券模板)创建,券模板的有效期以及券的库存信息。
  • 要发券,会涉及到券记录的创建和管理(过期时间,状态)

因此,我们可以将需求先简单拆解为两部分:

  • 管理端:管理活动、券模板配置数据
  • C端:查看活动,查询优惠券、领券、用券

同时,无论是活动、券模板还是券记录,都需要开放查询接口,支持券模板/券记录的查询。

系统选型及中间件

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

存储

由于活动、券模板、券记录这些都是需要持久化的数据,同时还需要支持条件查询,所以我们选用常用的数据库 MySQL 作为存储中间件。

缓存

由于发券时需要券模板信息,大流量情况下,不可能每次都从 MySQL 获取券模板信息,因此考虑引入缓存。

同理,券的库存管理,或者叫库存扣减,也是一个高频、实时的操作,因此也考虑放入缓存中。 我们选用 Redis 作为缓存中间件。

消息队列

由于券模板/券记录都需要展示过期状态,并且根据不同的状态进行业务逻辑处理,因此有必要引入延迟消息队列来对券模板/券状态进行处理。

系统框架

发券系统作为下游服务,是需要被上游服务所调用的。

公司内部服务之间,采用的都是 RPC 服务调用,系统开发语言使用的是 Golang,因此我们使用 gRPC

我们采用 gRPC+MySQL+Redis+Kafka 来实现发券系统,RPC 服务部署在AWS的EC2中。

系统设计实现

系统整体架构

从需求拆解部分我们对大致要开发的系统有了一个了解,下面给出整体的一个系统架构,包含了一些具体的功能。

管理端API:

  • 活动管理:活动的创建、修改、删除
  • 券模板管理:券模板的创建、修改、删除
  • 券记录管理:券记录的查询、统计分析 - OLAP

C端API:

  • 活动和券模板查询: GetCampaign, GetCouponMeta
  • 领券券记录查询: ListCouponRecord
  • 发放优惠券: ClaimCoupon
  • 锁定优惠券:LockCoupon
  • 核销优惠券: ConsumeCoupon
  • 释放优惠券: UnlockCoupon

数据库设计

与系统架构对应的,我们需要建立对应的 MySQL 数据存储表。

camapgin 活动表

CREATE TABLE `campaign_tab` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL COMMENT '活动名称',
  `description` varchar(255) NOT NULL COMMENT '活动描述',
  `campaign_type` tinyint(4) NOT NULL COMMENT '活动类型',
  `start_time` datetime NOT NULL COMMENT '活动开始时间',
  `end_time` datetime NOT NULL COMMENT '活动结束时间',
  `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '活动状态',
  `created_at` datetime NOT NULL COMMENT '创建时间',
  `updated_at` datetime NOT NULL COMMENT '更新时间',
  `created_by` varchar(255) NOT NULL COMMENT '创建人',
  `approved_by` varchar(255) NOT NULL COMMENT '审批人',
  `ext_data` JSON COMMENT '扩展字段',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

coupon_meta 券模板表

CREATE TABLE `coupon_meta_tab` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) NOT NULL COMMENT '券模板名称',
  `logo` varchar(255) NOT NULL COMMENT '券模板logo',
  `description` varchar(255) NOT NULL COMMENT '券模板描述',
  `coupon_type` varchar(255) NOT NULL COMMENT '券模板分类',
  `status` tinyint(4) NOT NULL COMMENT '券模板状态',
  `product_line` varchar(255) NOT NULL COMMENT '产品线',
  `coupon_count` int(11) NOT NULL COMMENT '券数量',
  `coupon_code` char(6) NOT NULL COMMENT '券码', -- 6位随机码
  `created_at` datetime NOT NULL COMMENT '创建时间',
  `updated_at` datetime NOT NULL COMMENT '更新时间',
  `created_by` varchar(255) NOT NULL COMMENT '创建人',
  `approved_by` varchar(255) NOT NULL COMMENT '审批人',
  `ext_data` JSON COMMENT '扩展字段',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

coupon_record 券记录表

CREATE TABLE `coupon_record_tab_[000-999]` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `coupon_template_id` int(11) NOT NULL COMMENT '券模板id',
  `coupon_code` varchar(255) NOT NULL COMMENT '券码',
  `status` tinyint(4) NOT NULL COMMENT '券状态',
  `user_id` int(11) NOT NULL COMMENT '用户id',
  `used_time` datetime DEFAULT NULL COMMENT '使用时间',
  `created_at` datetime NOT NULL COMMENT '创建时间',
  `updated_at` datetime NOT NULL COMMENT '更新时间',
  `ref_order_no` varchar(255) DEFAULT NULL COMMENT '外部关联订单号',
  `ext_data` JSON COMMENT '扩展字段',
  PRIMARY KEY (`id`)
  INDEX `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

核心逻辑实现

发券

发券流程分为三部分:参数校验、幂等校验、库存扣减。

幂等操作用于保证发券请求不正确的情况下,业务方通过重试、补偿的方式再次请求,可以最终只发出一张券,防止资金损失。

券过期

券过期是一个状态推进的过程,这里我们使用 MQ 来实现。

高并发场景下的问题及解决方案

实现了系统的基本功能后,我们来讨论一下,如果在大流量、高并发的场景下,系统可能会遇到的一些问题及解决方案。

存储瓶颈及解决方案

存款瓶颈

在系统架构中,我们使用了 MySQL、Redis 作为存储组件。我们知道,单个服务器的 I/O 能力终是有限的,在实际测试过程中,能够得到如下的数据:

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

针对MySQL:

  • 读写分离。在查询券模板、查询券记录等场景下,我们可以将 MySQL 进行读写分离,让这部分查询流量走 MySQL 的读库,从而减轻 MySQL 写库的查询压力。
  • MySQL分库分表。发券,归根结底是要对用户的领券记录做持久化存储。 对于 MySQL 本身 I/O 瓶颈来说,我们可以在不同服务器上部署 MySQL 的不同分片,对 MySQL 做水平扩容。 这样一来,写请求就会分布在不同的 MySQL 主机上,这样就能够大幅提升 MySQL 整体的吞吐量。 给用户发了券,那么用户肯定需要查询自己获得的券。 基于这个逻辑,我们以 user_id 后四位为分片键,对用户领取的记录表做水平拆分,以支持用户维度的领券记录的查询。

Redis记录发券数量。每种券都有对应的数量,在给用户发券的过程中,我们是将发券数记录在 Redis 中的,大流量的情况下,我们也需要对 Redis 做水平扩容,减轻 Redis 单机的压力。

存储容量预估

基于上述思路,在要满足发券 10w QPS 的需求下,我们预估一下存储资源。

a. MySQL 资源

在实际测试中,单次发券对 MySQL 有一次非事务性写入,MySQL 的单机的写入瓶颈为 10000 TPS,据此可以计算我们需要的 MySQL 主库资源为:

  • 100000/10000 = 10

b. Redis 资源

假设 12w 的发券 QPS,均为同一券模板,单分片的写入瓶颈为 2w,则需要的最少 Redis 分片为:

  • 100000/20000 = 5

库存问题及解决方案

问题

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

解决方案:

热点库存的问题,业界有通用的方案:即,扣减的库存 key 不要集中在某一个分片上。如何保证这一个券模板的 key 不集中在某一个分片上呢,我们拆 key(拆库存)即可。如图:

在业务逻辑中,我们在建券模板的时候,就将这种热点券模板做库存拆分,后续扣减库存时,也扣减相应的子库存即可。

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

问题

高 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 仪表盘,以保证能够最快观测到系统异常。 同时,对于一些异常情况,我们还有完善的报警机制,从而能够第一时间感知到系统的异常。

限流

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

资源隔离

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

压测

新服务上线前,首先需要对服务进行压测。这里总结一下压测可能需要注意的一些问题及压测结论。

注意事项

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

    • 找到单实例瓶颈
    • 找到 MySQL 一主的写瓶颈、读瓶颈
    • 找到 Redis 单分片写瓶颈、读瓶颈 得到了上述数据后,我们就可以粗略估算所需要的资源数,进行服务整体的压测了。
  • 2、压测资源也很重要,提前申请到足量的压测资源,才能合理制定压测计划。

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

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

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

总结

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

在系统开发实现过程中,对核心的发券、券过期实现流程进行了阐述,并针对大流量、高并发场景下可能遇到的存储瓶颈、热点库存、券模板缓存获取超时的问题提出了对应的解决方案。

其中,我们使用了分治的思想:

  • 对存储中间件进行水平扩容以解决存储瓶颈
  • 采取库存拆分子库存思路解决热点库存问题
  • 引入本地缓存解决券模板从 Redis 获取超时的问题。

最终保证了优惠券系统在大流量高并发的情景下稳定可用;

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

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