概述
上一篇文章中,我们介绍了订单系统秒杀与抢购的设计原则、挑战及常用方案。 本文就来介绍一个现实可行且实际工作的秒杀流程详细设计,以及面临的各种问题与应对方案。
流程图
流程及组件介绍
组件介绍
秒杀系统采用多机器,多线程并发处理模式,通过 redis 的 hash 结构的两个 key 来储存货品库存与抢购成功的订单ID和下单时间。 为了保护 redis,拒绝过大流量,每台机器所有线程共享一个 ConcurrentHashMap,作为是否有库存剩余的开关。
流程介绍
基础流程分为主流程与两个 crontab 流程。
- 主流程(多机器多线程并发执行) 主流程接收秒杀单下单请求,进行库存操作与抢购流程。 主要逻辑是: 1. 先检查当前机器共享的 ConcurrentHashMap 中对应货品的库存剩余开关是否已打开,未打开则直接拒绝访问,减轻服务器压力 2. 对于开关已打开的货品,调用 redis HINCRBY -1 指令将对应 field 进行减销量操作,由于 HINCRBY 的原子性,可以保证并发安全性 3. HINCRBY -1 操作可能返回任意数值,如大于等于 0,则意味着抢购成功,等于 0 则意味着下单后库存不足,需要将 ConcurrentHashMap 中对应货品的库存剩余开关关闭,拒绝此后下单请求,如返回小于 0,则意味着此次库存扣减操作有误,需要执行 HINCRBY 将库存加 1,并判断返回值是否大于 0,大于 0 则再次开启剩余开关 4. 抢购成功正常执行下单流程,并将下单成功的 orderid 与 ordertime 写入 redis 的 seckillsuccess,如下单过程中因各种原因下单失败,则返回下单失败,执行 HINCRBY 将库存加 1,并判断返回值是否大于 0,大于 0 则再次开启剩余开关
- crontab 10sec(所有机器各部署一个,可视情况决定运行周期,如每秒运行一次保证同步性) 每台机器每 10 秒周期执行一次以下流程,以便防止库存有剩余但线程中开关关闭的情况。 1. 取出所有当前所有线程共享的 ConcurrentHashMap 中货品剩余开关关闭的 dealid 列表 2. 对每个 dealid 查询 redis 的 seckill key 3. 如 redis 中库存量大于 0 则打开剩余开关
- crontab 1min(单机部署) 每分钟执行一次以下流程: 1. 遍历 redis 的 seckillsuccess key,取出 ordertime 在十分钟前的 orderid 列表 2. 对于 orderid 列表中未支付或支付失败的订单更新数据库订单状态为已取消,更新 seckill 对应货品库存 1
涉及的主要问题
为什么主流程下单扣减库存的操作可能返回小于 0?
在非并发情况下,由于只有库存大于 0 才会允许下单(共享的 ConcurrentHashMap 中的开关打开),因此执行扣减操作后,返回最小值为 0,不可能出现小于 0 的情况。 但是在并发场景下,由于没有加锁(出于性能考虑),在当前线程判断共享的 ConcurrentHashMap 中开关处于打开状态到扣减销量的时间间隔中,可能有若干个线程同样判断开关处于打开状态而执行扣减库存操作,致使当前线程扣减库存前,库存已小于等于 0。 同样,当前线程扣减库存返回值小于 0 到执行接下来的步骤的过程中,可能又有若干个线程进行过加库存操作,致使此后库存值大于 0,因此当前线程在发现返回值小于 0 之后不能简单地执行 SET 0 操作,这将导致库存值少于实际值,造成未卖光的情况。
为什么需要单机部署 crontab 1min 流程
由于秒杀单的特殊性,用户下单成功后 10 分钟内未支付或支付失败,将取消该订单,因此,按分钟为粒度查询用户支付情况既避免对 redis 造成额外压力,又可以及时增加库存重新抢购。
其他设计方案有: 1. 使用外部延时消息队列处理,好处是可以自定义超时时间,实现灵活,到时后立即执行相应判断,增加即时性,但考虑系统设计的简洁性,不增加额外依赖组件,没有采取 2. 使用 java 原生 DelayQueue 容器结构,好处在于轻量化,即时性,但对系统稳定性要求较高,某个线程挂掉将直接导致内存中该部分数据丢失,从稳定性上考虑没有采取
ConcurrentHashMap 中的货品剩余开关是否可靠
ConcurrentHashMap 中的货品剩余开关初始为全部开启状态,一旦检查确实库存不足则立即关闭。 设计初衷是: 1. 不保证货品剩余开关开启的情况下,货品一定有剩余,因为货品库存情况以 redis seckill 中实际库存值为准 2. 但是如果货品剩余开关关闭的时刻,货品一定已被抢光 crontab 10sec 流程以 10 秒为粒度对上述进行了保证,同时,系统初始化启动或异常重启后,所有开关开启,不会造成数据错误的问题。
设计说明
秒杀系统属于瞬时高请求量,但有效请求量低,因此需要对后端存储系统进行保护,同时,由于秒杀对即时性要求性高,需要使用同步策略。 鉴于以上实际情况,在系统中,首先采用了每台机器所有线程共享的并发容器存储开关来防止不必要的请求到达后端数据库,其次,使用 redis 集群缓存保护后端数据库,这样两层保护,让数据库压力降为最低,仅有实际的有效请求。 具体流程中,采取可重入的无锁设计,依赖 redis 的院子操作保证数据的并发安全性,可重入系统保证了在异常情况发生时,不会出现超卖、少卖等数据错误情况,同时,无锁的设计让系统性能更高。
实现代码
spring 操作 redis 可以参考: Spring 调用 Redis — RedisTemplate 的基本配置和使用
代码语言:javascript复制package com.techlog.test.service;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Created by techlog on 16/8/16.
*/
@Service
public class SeckillService {
@Resource
private RedisTemplate<String, String> redisTemplate;
private static Map<Long, Integer> dealSaleOut = new ConcurrentHashMap<>();
private static long orderid = 1805267L;
private static final Object lock = new Object();
public String createSeckill() {
Long dealid = 1100110L;
if (dealSaleOut.containsKey(dealid) && dealSaleOut.get(dealid) >= System.currentTimeMillis()/1000 - 5) {
return "SeckillInfo: 货品已售罄 (dealSaleOut in 5 sec)";
}
Long inventory = redisTemplate.execute((RedisCallback<Long>) redisConnection ->
redisConnection.hIncrBy("seckill".getBytes(), dealid.toString().getBytes(), -1));
if (inventory >= 0) {
if (inventory == 0) {
dealSaleOut.put(dealid, (int) (System.currentTimeMillis()/1000));
}
boolean result = createOrder();
if (!result) {
redisTemplate.execute((RedisCallback<Long>) redisConnection ->
redisConnection.hIncrBy("seckill".getBytes(), dealid.toString().getBytes(), 1));
return "SeckillInfo: 下单失败";
} else {
return "SeckillInfo: 下单成功 " orderid;
}
} else {
dealSaleOut.put(dealid, (int) (System.currentTimeMillis()/1000));
redisTemplate.execute((RedisCallback<Long>) redisConnection ->
redisConnection.hIncrBy("seckill".getBytes(), dealid.toString().getBytes(), 1));
return "SeckillInfo: 货品已售罄";
}
}
private boolean createOrder() {
int score = (int) (Math.random() * 10);
if (score >= 3) {
synchronized (lock) {
orderid ;
}
return true;
}
return false;
}
}
改进
上述代码中,主要对控制秒杀项目库存进行了模拟,下单流程通过随机数模拟了 70% 的下单成功率。 代码中对上面的流程有以下两个改进点: 1. 取消 crontab 10sec 线程,取而代之的是将 ConcurrentHashMap 的 value 类型换成了 Integer 存储售罄时间戳,这个时间戳有 10 秒的过期时间,一旦时间过期,则强制查询 redis 查看库存 2. 取消 redis 的 seckillsuccess key,crontab 1min 通过直接扫描数据库实现未支付订单取消的功能,主要原因是在实际测试中,redis 的 hset 无法保证成功,致使可能出现下单成功但是未加入 seckillsuccess 中的情况出现
需完善的要点
1. 系统的各项情况监控,java 线程 GC 情况、机器CPU、内存、网络负载的监控、报警与自动降级等 2. 十分钟未支付请求如需实时处理可增加延时队列设计 3. 增加线程库存开关的实时性可以降低 crontab-10sec 流程的运行时间间隔
另一种设计方案 — 使用队列
秒杀系统同样可以使用消息队列来进行设计,由于生产、消费消息的原子性与消息队列的抗并发能力,这也是一种非常好的方案。 每个货品 id 创建一个队列,初始状态该队列中消息数即该货品库存量,每个下单流程线程都作为在收到请求后尝试以非阻塞的方式获取对应队列的消息,取到消息则进入下单流程,未取到消息则返回抢购失败。 下单过程出现任何异常,消息会被自动放回到消息队列中,无需额外处理,也无需维护队列与线程间的同步。 同时,由于使用队列作为依赖,可以很方便的实现下单成功后的未支付延时队列。 但消息队列存取、同步、持久化等的效率远低于 redis 等缓存。