优惠券系统主要涵盖四个核心能力:创建、派发、使用、统计。本篇主要针对派发这部分,在系统设计和落地过程中遇到和解决的一些问题做一个简单记录,以便后来补缺。
关注腾讯云开发者,一手技术干货提前解锁👇
优惠券是电商常见的营销手段,是营销平台中的一个重要组成部分,腾讯云 MALL 也需要搭建优惠券相关的平台能力来更好的助力赋能商家的各种促销场景。
这里找了几个电商平台的优惠券相关页面:
依次是某东、某宝、腾讯云 MALL ,这里各式各样的优惠券背后涉及的相关系统,可以统称为优惠券系统。所以单说优惠券系统是一个很庞大的系统,这里收敛一下讲其中主要有四大核心能力:创建、派发、使用、统计。
2.1 系统架构
本篇主要介绍的是平台如何创建和派发优惠券到用户账户的券包里,即上面提到的四大核心能力中的创建和派发。
3.1 核心概念
先简单了解一下两个概念:优惠券批次、优惠券。
3.2 批次表核心字段
3.3 优惠券表核心字段
批次表的数据写入主要是 B 端后台管理来操作,这里不多赘述。
优惠券表数据主要通过派发动作与用户关联后写入,后面会展开介绍。
3.4 B 端配置效果
4.1 两大主要问题
流量毛刺示意:
4.2 主流程拆解
4.2.1 库存扣减
最终方案:
Redis+Lua+库存异步分段增补:
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
分段增补示意:
介绍:
每当 Redis 剩余库存小于 M 个时,异步从数据库增补 N 个库存到 Redis 里,保证 Redis 库存数量一直小于等于数据库。
主流程如图:
4.2.2 生成优惠劵
最终方案:
介绍:
通过消息异步生成优惠券落库处理来支持高并发,引入一张本地事务表达成数据的最终一致性。
主流程如图:
数据参考:
示意:
5.1 热点问题
回顾整体方案,同批次场景仍存在热点问题,针对这里可以做一些优化来提升系统性能,如:资源分桶,聚合扣减,热点更新技术等。如何解决热点问题?下面结合发券场景列举几种方案做一下对比介绍,可供参考。
热点示意:
5.1.1 资源分桶
简介:同一个批次的库存分成多份,通过分散库存扣减请求提升性能。
优势:水平扩展能力强。
重点关注:
5.1.2 聚合扣减
简介:聚合相同批次的请求统一扣减,通过聚合请求量来提升服务整体性能。
优势:前置聚合请求利于提高服务稳定性。
重点关注:
5.1.3 热点更新
简介:热点更新技术详细介绍见腾讯云文档:
https://cloud.tencent.com/document/product/237/13402
优势:适用数据库锁层面的热点优化。
重点关注:
小结:每种方案的实现均有利有弊,最后都需要在系统性能和复杂度上做权衡取舍,最终选出契合自身实际业务的才是最好的方案。
-End-
原创作者|管振盼