万级TPS优惠券系统设计与实践

d32d34f59923392f93f13a29d6fda57e.png

f812abd102f017812830a4cae4561b1a.gif

👉目录

1 背景介绍

2 什么是优惠券系统?

3 优惠券创建

4 优惠券派发

5 后续优化

优惠券是电商常见的营销手段,是营销平台中的一个重要组成部分,既可以作为促销活动的载体,也是重要的引流入口。在刚刚过去的电商大促周期内,各大电商平台都有配置不同类目、价位的优惠券,吸引用户下单购买。

优惠券系统主要涵盖四个核心能力:创建、派发、使用、统计。本篇主要针对派发这部分,在系统设计和落地过程中遇到和解决的一些问题做一个简单记录,以便后来补缺。

关注腾讯云开发者,一手技术干货提前解锁👇

01

背景介绍

优惠券是电商常见的营销手段,是营销平台中的一个重要组成部分,腾讯云 MALL 也需要搭建优惠券相关的平台能力来更好的助力赋能商家的各种促销场景。

02

什么是优惠券系统?

这里找了几个电商平台的优惠券相关页面:

cef44b15ca77c57d7e232ed64c019360.png

依次是某东、某宝、腾讯云 MALL ,这里各式各样的优惠券背后涉及的相关系统,可以统称为优惠券系统。所以单说优惠券系统是一个很庞大的系统,这里收敛一下讲其中主要有四大核心能力:创建、派发、使用、统计。

   2.1 系统架构

cbf5258474390ad17eec91d24ed619b0.png

本篇主要介绍的是平台如何创建和派发优惠券到用户账户的券包里,即上面提到的四大核心能力中的创建和派发。

03

优惠券创建

   3.1 核心概念

先简单了解一下两个概念:优惠券批次、优惠券。

  1. 优惠券批次:一批相同优惠券的生成模版。

  2. 优惠券:根据批次信息生成,优惠券与批次的对应关系是 N:1。

f06b2f2143eb24c2d29018dfcdb40a2b.png

   3.2 批次表核心字段

  1. 批次 ID ;

  2. 优惠券名称;

  3. 优惠券类型;

  4. 库存数量;

  5. 优惠规则如:满减,满折等;

  6. 生效规则:固定生效时间、领取后生效时间等;

  7. 领取规则:批次每天限领数量、用户每天限领数量、用户总限领数量等;

  8. 使用规则:指定商家、指定商品、指定类目、指定场景等。

   3.3 优惠券表核心字段

  1. 优惠券 ID:分布式 ID 全局唯一

  2. 批次 ID;

  3. 用户 ID;

  4. 优惠券状态;

  5. 上下文信息。

批次表的数据写入主要是 B 端后台管理来操作,这里不多赘述。

优惠券表数据主要通过派发动作与用户关联后写入,后面会展开介绍。

   3.4 B 端配置效果

39034bdda20bdb6b975353037c88e2cc.png

84726639b327d4b1980614d5640b98d2.png

04

优惠券派发

   4.1 两大主要问题

  1. 库存管理,如何防止超发,保障库存安全。

  2. 场景复杂,如何支持高并发及瞬时高流量毛刺场景。

流量毛刺示意:

9e36bf198cbb4a4559920a7b3489059a.png

   4.2 主流程拆解

  1. 库存扣减;

  2. 生成优惠券。

   4.2.1 库存扣减

  1. 直接用数据库做库存管理,面临问题:高并发导致数据库崩溃、性能瓶颈明显。

  2. 缓存做库存管理:数据不一致、穿透、击穿、雪崩等问题。

最终方案:

Redis+Lua+库存异步分段增补:

  1. Redis+Lua:支持高并发库存扣减。

  2. 库存异步分段增补:支持高并发的前提下灵活分配库存。

Lua 脚本示意(示意代码仅供学习参考):

--批次的HashKey
local stockKey = KEYS[1];


--Argv 参数
local stockId = ARGV[1];
local couponId = ARGV[2];
local uid = ARGV[3];
--该批次当天最大发放量
local maxByDay = ARGV[4];
-- 每人限领
local maxByUser = ARGV[5];
--当前时间Str
local crtDateStr = ARGV[6];
-- 每人每日限领
local dailyMaxByUser = ARGV[7];


stockId = tonumber(stockId);
maxByUser = tonumber(maxByUser);
maxByDay = tonumber(maxByDay);
dailyMaxByUser = tonumber(dailyMaxByUser);


--StockKey nil
if not stockKey then
    return '-4'
end
--Argv nil
if not stockId or not couponId or not uid or not maxByUser or not maxByDay or not crtDateStr or not dailyMaxByUser then
    return '-5'
end


local leftAmountField = 'left_amount';
local res = redis.call("HMGET", stockKey, leftAmountField, crtDateStr);
local leftAmount = tonumber(res[1]);
local crtDispatchAmount = tonumber(res[2]);
local couponIdSetKey = stockKey .. ':coupon:zset';


--优惠券ID是否已经分配库存
local score = redis.call("ZSCORE", couponIdSetKey, couponId);
-- couponId 已经存在
if score then
    return '-6';
end


-- 库存不足
if not leftAmount or leftAmount <= 0 then
    return '-3';
end


--达到当天发放上限
if crtDispatchAmount and crtDispatchAmount >= maxByDay then
    return '-1';
end


-- 该批次每人每日领取数量HashKey
local dailyUserAcquireNumKey = stockKey .. ":user:acquire:" .. crtDateStr;
if dailyMaxByUser > 0 then
  local dailyUserAcquireNum = redis.call("HGET", dailyUserAcquireNumKey, uid);
  dailyUserAcquireNum = tonumber(dailyUserAcquireNum);
  -- 达到每人每日领取上限
  if dailyUserAcquireNum and dailyUserAcquireNum >= dailyMaxByUser then
      return '-7'
  end
end


--该批次用户领取数量HashKey
local userAcquireNumKey = stockKey .. ":user:acquire";
local usrAcquireNum = redis.call("HGET", userAcquireNumKey, uid);
usrAcquireNum = tonumber(usrAcquireNum);


--达到用户领取上限
if usrAcquireNum and usrAcquireNum >= maxByUser then
    return '-2'
end


--扣减库存-1
local leftAmountAfterOp = redis.call("HINCRBY", stockKey, leftAmountField, -1);
--当天发放量+1
local crtDispatchAmountAfterOp = redis.call("HINCRBY", stockKey, crtDateStr, 1);
--当前用户发放量+1
local usrAcquireNumAfterOp = redis.call("HINCRBY", userAcquireNumKey, uid, 1);
-- 当前用户当天发放量+1
local dailyUserAcquireNumAfterOp = redis.call("HINCRBY", dailyUserAcquireNumKey, uid, 1);


redis.call("ZADD", couponIdSetKey, uid, couponId);


--返回操作之后的上下文,缓存中剩余量,当天已经发放量,用户已经领取量,用户当天已经领取量
return '0|' .. leftAmountAfterOp .. '|' .. crtDispatchAmountAfterOp .. '|' .. usrAcquireNumAfterOp .. '|' .. dailyUserAcquireNumAfterOp

分段增补示意:

4141fd32098ed92c0ff6840668a2de34.png

介绍:

每当 Redis 剩余库存小于 M 个时,异步从数据库增补 N 个库存到 Redis 里,保证 Redis 库存数量一直小于等于数据库。

  1. 屏蔽流量直接打到数据库,减轻数据库压力。

  2. Redis+数据库控制,双重保证不超发。

  3. 库存增补的 M 和 N 可以根据实际业务需要灵活调配。

    1. M 可以理解为业务发券速率兜底。比如:发快补慢提示无库存等。

    2. N 可以理解为极端情况下最大允许丢失的库存数量。

主流程如图:

ff3e7d3b52926a330565de9aa936f64e.png

   4.2.2 生成优惠劵

  1. 扣减库存成功同步生成优惠券信息写入数据库,同样会面临高并发导致数据库崩溃的问题,系统瓶颈明显不可取。

  2. 这里再加缓存的话,解决缓存问题会让业务变得更复杂,结合第二个主要问题:瞬时高流量毛刺。

最终方案:

  1. 库存扣减成功后异步生成优惠券,达到整体流程支持高并发,且可以解决流量毛刺的问题。PS:分布式事务问题。

  2. 结合自身业务场景,对比权衡了多种分布式事务解决方案,最终选用本地事务表+最大努力通知来解决分布式事务问题。

df10e7deb24d474d57941f4a0a56d3ff.png

介绍:

通过消息异步生成优惠券落库处理来支持高并发,引入一张本地事务表达成数据的最终一致性。

主流程如图:

e08e06c34345f60369243f744f08854e.png

数据参考:

  1. 结合自身实际业务测试环境压测目标 1W/TPS 示意(系统整体支持横向扩容进一步提升性能)。

示意:

3a83762d6c035b384ba31835ca70a5be.png

05

后续优化

   5.1 热点问题

回顾整体方案,同批次场景仍存在热点问题,针对这里可以做一些优化来提升系统性能,如:资源分桶,聚合扣减,热点更新技术等。如何解决热点问题?下面结合发券场景列举几种方案做一下对比介绍,可供参考。

热点示意:

bee67b3a531dc6f6968739a270e76ded.png

   5.1.1 资源分桶

简介同一个批次的库存分成多份,通过分散库存扣减请求提升性能。

1e74f3204f8192973f1cd210746e6bda.png

优势:水平扩展能力强。

重点关注:

  1. 分桶 Key 路由倾斜问题,理想情况是所有 Key 平均对应分桶。

  2. 各桶之间库存倾斜与性能权衡的问题,理想情况是所有分桶消耗速率一致。

   5.1.2 聚合扣减

简介:聚合相同批次的请求统一扣减,通过聚合请求量来提升服务整体性能。

96e5e008e9e094ce73225724adf2dce1.png

优势:前置聚合请求利于提高服务稳定性。

重点关注:

  1. 聚合策略的设计需要在系统稳定性和性能上做取舍。

  2. 临界库存如何与聚合策略适配的问题。

   5.1.3 热点更新

简介:热点更新技术详细介绍见腾讯云文档:

https://cloud.tencent.com/document/product/237/13402

优势:适用数据库锁层面的热点优化。

重点关注:

  1. 依赖数据库适用场景较单一。

小结:每种方案的实现均有利有弊,最后都需要在系统性能和复杂度上做权衡取舍,最终选出契合自身实际业务的才是最好的方案。

-End-

原创作者|管振盼

 158fbe00bc0596b949e38846b4a50ef6.png

关于优惠券系统的设计,你还有哪些心得体会?欢迎评论留言。我们将选取1则优质的评论,送出腾讯Q哥公仔1个(见下图)。11月20日中午12点开奖。

35804d9fda55d108dc531d9198469c15.jpeg

📢📢欢迎加入腾讯云开发者社群,享前沿资讯、大咖干货,找兴趣搭子,交同城好友,更有鹅厂招聘机会、限量周边好礼等你来~

0627a96f21f22b7bc27f55fe3328934a.jpeg

(长按图片立即扫码)

f6245a8e2d76898081304c53dc0f067f.png

f3b6aaddd96c2eea6901b0afd67e14bf.png

774e72dc3a5891c682b481657ca8c316.png

1d3de451e6e137c7a50d5b0eb6f2270d.png

0cfe5f3736f7b0744103f4c99bcb76d7.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值