年终活动付费产品秒杀系统设计及实践

232 阅读7分钟

一、背景

为感恩回馈用户对过去一年的支持,在年终活动中通过极具吸引力的低价,限量售卖付费产品让利用户,同时提升品牌知名度, 因此需要设计一套高效稳定的秒杀系统应对瞬时高并发流量冲击,保障年终活动的顺利开展。

二、秒杀架构设计

在这里插入图片描述

整体设计采用分层设计,接口中通过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万以上),保障支付签约和付款流程的顺利完成,助力年终活动顺利开展。