一、背景
为感恩回馈用户对过去一年的支持,在年终活动中通过极具吸引力的低价,限量售卖付费产品让利用户,同时提升品牌知名度, 因此需要设计一套高效稳定的秒杀系统应对瞬时高并发流量冲击,保障年终活动的顺利开展。
二、秒杀架构设计
整体设计采用分层设计,接口中通过dubbo框架对外暴露rest协议并注册到网关,C端用户通请求网关进行秒杀。
三、秒杀流程
- 设计秒杀流程时遵循轻量校验尽量前置的原则,提前拦截更多流量。
- 在前端和后端都通过策略对同用户限频(比如每5秒内只能请求1次),防止接口被刷。
- 创建订单和同步扣减MySQL库存时涉及到数据库操作,采用异步操作提升性能。
四、秒杀配置设计
1、表设计
当前场景需支持配置多个秒杀活动,每个秒杀活动里面配置多个秒杀场次,因此有主表和详情表,具体如下:
CREATE TABLE `sec_kill_activity` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键自增长',
`ac_id` varchar(40) DEFAULT NULL COMMENT '活动id',
`ac_name` varchar(50) DEFAULT NULL COMMENT '活动名称',
`start_time` datetime DEFAULT NULL COMMENT '活动开始时间',
`end_time` datetime DEFAULT NULL COMMENT '活动结束时间',
`user_group_type` tinyint(2) DEFAULT NULL COMMENT '客群类型:0-精准客群',
`user_group_name` varchar(100) DEFAULT NULL COMMENT '客群名称',
`user_group_code` varchar(50) DEFAULT NULL COMMENT '客群编码',
`bdp_code` varchar(100) DEFAULT NULL COMMENT '客群bdp编码',
`ac_status` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '活动状态:1-上线,2-下线',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_ac_id` (`ac_id`) USING BTREE
) ENGINE=InnoDB COMMENT='大促秒杀预付卡活动配置';
CREATE TABLE `sec_kill_activity_detail` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键自增长',
`sec_kill_id` varchar(32) NOT NULL COMMENT '秒杀场次id',
`ac_id` varchar(40) DEFAULT NULL COMMENT '活动id',
`num` int(11) DEFAULT NULL COMMENT '秒杀场次序号',
`product_type` tinyint(2) DEFAULT NULL COMMENT '秒杀卡类型:1-SVIP卡,2-亲情卡,3-特快卡',
`sec_kill_product_id` varchar(32) NOT NULL COMMENT '秒杀套餐ID',
`sec_kill_product_name` varchar(40) DEFAULT NULL COMMENT '秒杀套餐名称',
`sec_kill_product_price` varchar(20) DEFAULT NULL COMMENT '秒杀套餐价格',
`origin_product_id` varchar(32) NOT NULL COMMENT '原价套餐ID',
`origin_product_name` varchar(40) DEFAULT NULL COMMENT '原价套餐名称',
`origin_product_price` varchar(20) DEFAULT NULL COMMENT '原价套餐价格',
`start_time` datetime DEFAULT NULL COMMENT '秒杀开始时间',
`end_time` datetime DEFAULT NULL COMMENT '秒杀结束时间',
`stock` int(11) DEFAULT NULL COMMENT '实时场次库存',
`origin_stock` int(11) DEFAULT NULL COMMENT '初始场次库存',
`button_label` varchar(500) DEFAULT NULL COMMENT '按钮标签',
`product_desc` varchar(500) DEFAULT NULL COMMENT '套餐介绍',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_sec_kill_id` (`sec_kill_id`) USING BTREE,
KEY `idx_ac_id` (`ac_id`) USING BTREE
) ENGINE=InnoDB COMMENT='大促秒杀预付卡活动配置场次';
2、缓存设计
活动中每天开始的秒杀场次信息不能再修改(比如不能增加库存等),因此将活动配置刷入由Redis和Caffine组成的两级缓存中提升访问效率。为了避免big key出现,需要精心设计缓存key。
| 缓存key名字 | 缓存key | 缓存后缀 | 备注 |
|---|---|---|---|
| 单个秒杀场次库存 | sk:stock:%s | 秒杀场次id | |
| 单个秒杀场次信息 | sk:detail:%s | 秒杀场次id | |
| 存放当天秒杀场次信息 | sk:day:%s:%s | 活动id、秒杀日期 | |
| 所有秒杀活动缓存信息 | sk:ac | 无id | 只存活动id、开始结束时间 |
上述信息除库存外,其他都存入两级缓存,利用redis发布订阅实现redis变更时实时更新到本地缓存Caffeine,通过API显式构建caffeine缓存。
public class SecSkillCache {
@Autowired
private SfRedisUtil redisUtil;
private LoadingCache<String, String> cache = Caffeine.newBuilder()
.refreshAfterWrite(2, TimeUnit.HOURS)
.expireAfterWrite(6, TimeUnit.HOURS)
.initialCapacity(256)
.maximumSize(512)
.build(new CacheLoader<String, String>() {
@Nullable
@Override
public String load(@Nonnull String key) throws Exception {
String value = redisUtil.get(key);
if (StringUtils.isEmpty(value)) {
return null;
}
//发redis信息,让其他节点收到后更新本地缓存
redisUtil.getRedisTemplate().convertAndSend(key, value );
return value;
}
});
/**
* 删除缓存
* @param key
*/
public void evict(String key) {
cache.invalidate(key);
}
/**
* 删除所有缓存
*/
public void evictAll() {
cache.invalidateAll();
}
/**
* 更新缓存
* @param key
* @param value
*/
public void put(String key, String value) {
cache.put(key, value);
}
}
如果所有活动为空,不仅需要从本地缓存删除缓存key(sk:ac),还需要删除其他所有缓存key(sk:stock:%s等),此时发布消息时携带不同的value,redis收到监听回调时通过value执行删除所有缓存还是单个缓存key,如下所示:
@Component
public class SkillSecDetailListener extends AbstractRedisMessageListener {
private CaffeineCacheUtil caffeineCacheUtil;
private SecSkillCache secSkillDetailCache;
public SkillSecDetailListener(CaffeineCacheUtil caffeineCacheUtil, SecSkillCache secSkillDetailCache) {
super(caffeineCacheUtil);
this.caffeineCacheUtil = caffeineCacheUtil;
this.secSkillDetailCache = secSkillDetailCache;
}
@Override
public void onMessage(Message message, byte[] pattern) {
String data = VALUE_SERIALIZER.deserialize(message.getBody());
String channel = new String(message.getChannel());
//如果是删除redis缓存,则清除本地缓存 否则更新本地缓存或者插入 则直接覆盖
if (SfStrUtil.equals(CacheConstant.DELETE_FLAG, data)) {
secSkillDetailCache.evict(channel);
}else if (SfStrUtil.equals(CacheConstant.DELETE_FLAG_ALL, data)) {
secSkillDetailCache.evictAll();
}else if (StringUtils.isNotEmpty(data)) {
secSkillDetailCache.put(channel, data);
}
}
@Override
public Set<String> getTopics() {
return Sets.newHashSet(CacheConstant.RedisMessageTopic.SINGLE_SEC_DETAIL_KILL_INFO_PREFIX,
CacheConstant.RedisMessageTopic.DAY_SEC_KILL_LIST_PREFIX,
CacheConstant.RedisM
essageTopic.SEC_KILL_ACTIVITY);
}
@Override
public SubscribeType getSubscribeType() {
return SubscribeType.PATTERN_TYPE;
}
@Override
public String getCacheName() {
return null;
}
}
每天凌晨三点通过定时任务刷新到缓存系统。
五、库存管理
面对高并发流量,如果库存扣减直接更新数据库,更新操作会锁定行数据,笨重的数据库操作会拖慢秒杀下单接口,因此利用Redis高效读写操作来管理库存。活动开始前将库存刷到Redis,秒杀下单时只扣减Redis中的库存,再通过队列异步扣减MySQL数据,提升接口响应效率。
1、扣减库存
为了防止库存被扣减成负数(比如当前库存为2,扣减3),同时为了保证原子性,采用lua脚本实现库存扣减。扣减前先校验剩余库存是否大于即将要扣减的库存,如果满足则扣减否则认为扣减失败,具体如下:
local k = KEYS[1]
local v = tonumber(ARGV[1])
if redis.call('exists', k) == 1 then
local current_value = tonumber(redis.call('get', k))
if current_value >= v then
redis.call('decrby', k, v)
return true
else
return false
end
else
return false
end
2、库存回滚
local k = KEYS[1]
local v = tonumber(ARGV[1])
local exist_value=redis.call('get', k)
if exist_value==nil then
return false
end
if tonumber(exist_value) >= 0 then
redis.call('incrby', k, v)
return true
else
return false
end
3、lua脚本加载及执行
为了提升执行时传递效率,提前用RedissonClient加载lua脚本返回签名,执行lua脚本时传递签名和参数:
@Configuration
public class RedisLuaScriptConfiguration {
/**
* 扣减库存
* @return
*/
@Bean(name = "secSkillStockDeductScript")
public RedisScript<Boolean> secSkillStockDeductScript() {
DefaultRedisScript<Boolean> stockDeductScript = new DefaultRedisScript();
stockDeductScript.setResultType(Boolean.class);
stockDeductScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("script/sec_skill_stock_deduct_script.lua")));
return stockDeductScript;
}
/**
* 增加库存
* @return
*/
@Bean(name = "secSkillStockAddScript")
public RedisScript<Boolean> secSkillStockAddScript() {
DefaultRedisScript<Boolean> stockDeductScript = new DefaultRedisScript();
stockDeductScript.setResultType(Boolean.class);
stockDeductScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("script/sec_skill_stock_add_script.lua")));
return stockDeductScript;
}
}
@Configuration
public class LuaScriptConfig implements InitializingBean {
@Qualifier("secSkillStockDeductScript")
@Autowired
private RedisScript<Boolean> secSkillStockDeductScript;
@Qualifier("secSkillStockAddScript")
@Autowired
private RedisScript<Boolean> secSkillStockAddScript;
@Autowired
private SfRedisUtil sfRedisUtil;
private String DEDUCT_STOCK_SCRIPT_SHA;
private String ADD_STOCK_SCRIPT_SHA;
@Override
public void afterPropertiesSet() throws Exception {
DEDUCT_STOCK_SCRIPT_SHA = sfRedisUtil.scriptLoad(secSkillStockDeductScript.getScriptAsString());
ADD_STOCK_SCRIPT_SHA = sfRedisUtil.scriptLoad(secSkillStockAddScript.getScriptAsString());
}
public String getDeductStockScript() {
return DEDUCT_STOCK_SCRIPT_SHA;
}
public String getAddStockScript() {
return ADD_STOCK_SCRIPT_SHA;
}
}
* 扣减库存及回滚库存:
/**
* 扣减库存
* @param key
* @param value
* @return
*/
private boolean deductStock(String key, String value) {
return redisUtil.evalSha(luaScriptConfig.getDeductStockScript(), RScript.ReturnType.BOOLEAN, Collections.singletonList(key), value);
}
/**
* 增加库存
* @param key
* @param value
* @return
*/
public boolean addStock(String key, String value) {
return redisUtil.evalSha(luaScriptConfig.getAddStockScript(), RScript.ReturnType.BOOLEAN, Collections.singletonList(key), value);
}
六、稳定性保障
当前秒杀场景涉及多方联动:付费产品、支付、金融及第三方微信支付,此处重点介绍付费产品的稳定性保障工作:
1、接口限流
网关层面限流
2、外部接口熔断
每秒最小请求数至少为75,当响应时间大于2秒的比例超过20%时,熔断接口时长5秒
3、用户访问限频
为了防止用户频繁刷接口,前后端都需要对用户进行限频,此处主要讨论后端限频实现方式(比如每个用户5秒钟仅能访问1次),常见限流算法有:固定窗口、滑动窗口、令牌、漏洞方式等方式,结合当前场景采用比较简单的固定窗口实现,采用自定义注解RateLimit+redis+lua实现,具体如下:
- 注解切面
- lua脚本
-- 限流阈值
local rate = ARGV[3];
-- 限流单位(如1000ms内)
local interval = ARGV[4];
-- 限流类型 0-集群,1-单机
local type = ARGV[5];
-- 限流key
local valueName = KEYS[2]..':'..ARGV[4];
if type == 1 then
valueName = KEYS[3]..':'..ARGV[4];
end;
local currentValue = redis.call('get', valueName);
if currentValue ~= false then
if tonumber(currentValue) < tonumber(ARGV[1]) then
return redis.call('pttl', valueName);
else
redis.call('decrby', valueName, ARGV[1]);
return nil;
end;
else
redis.call('set', valueName, rate, 'px', interval);
redis.call('decrby', valueName, ARGV[1]);
return nil;
end;
六、总结
通过优化架构设计、简化秒杀流程、消息队列异步落库、提升库存管理效率、数次的压测调优及强化系统稳定性,秒杀系统成功支持年终活动开展21场秒杀活动,有效应对瞬间高并发流量的冲击(最高qps达到1万以上),保障支付签约和付款流程的顺利完成,助力年终活动顺利开展。