一.場景
秒殺下單Flow,一個會員,只能下一單。
下圖爲秒殺下單的簡單流程圖,其中除了<異步操作>外,其它操作均爲同步操作。
二.問題
1 前置業務校驗(包括活動,商品庫存和會員下單數量)如何實現。
2 扣減庫存和創建訂單的一致性如何處理。
3 補償業務如何實現(庫存回滾,訂單過時)。
三.分析
1 秒殺場景的併發點可以分爲兩個,商品扣減庫存和會員下單。商品扣減庫存是全局的,而會員下單隻針對單個會員。
2 高併發場景下,爲了減少無效請求進入,應該儘量把校驗操作放到前面。
3 因爲秒殺場景的流量大,所以這裏的校驗不能直接查詢數據庫,涉及到庫存的操作還需要保證一致性的問題。
4 靜態數據基本不會更改,作爲緩存進行校驗相對簡單,所以主要問題還是處理庫存和下單的一致性。
四.方案
1. 業務校驗應該放在扣減庫存前,目的是過濾無效請求。
2. 活動狀態和時間基本上是不會變,所以可以直接用緩存。可以把活動信息存儲在redis的hashmap中,然後以當前時間爲參數,採用lua腳本把活動的查詢和校驗串行執行,用於判斷活動的有效性。
3. 如果每人只能下一單,那麼在下單成功後,可以用bitmap記錄會員ID,如果每人可以多單,那麼可以用incrby+lua,把活動_商品_會員作爲key,商品數量或者訂單數量作爲value,,前置業務校驗對每個請求進行過濾,最後進來的只會是有效的請求。
4. 爲了保證庫存和訂單的一致性和時效性,先扣減庫存,然後再下單(這裏需要對單個會員進行鎖定),如果下單失敗,需要提供庫存回滾處理,在事務中先更新數據庫,再decrby緩存。如果回滾失敗,可以通過日誌監控進行人工處理(這裏考慮到一般失敗的概率比較低)。
5. 訂單過時的處理,可以用定時任務進行監控,如果時效性比較高的話可以用延時隊列,取消訂單後,通過mq來回滾庫存(定時任務可以使用quartz或者elastic-job,延時隊列可以用redis,或者其它mq作有序消費)。
五.僞代碼
分佈式鎖代碼(參考 Jedis 實現簡單的分佈式鎖):
--因爲秒殺場景的請求量比較大,所以這裏會改成fast-fail機制,不進行輪循
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
String result = null;
Jedis jedis = null;
if (Thread.currentThread().isInterrupted()) {
evalUnLock(jedis);
throw new InterruptedException();
}
try {
jedis = jedisPool.getResource();
result = jedis.set(lockKey, lockUserId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, unit.toMillis(time));
} catch (Exception e) {
e.printStackTrace();
} finally {
jedis.close();
}
if (!LOCK_SUCCESS.equals(result)) {
return false;
} else {
return true;
}
}
商品庫存扣減代碼:
package test.cache;
import redis.clients.jedis.Jedis;
import java.util.Arrays;
public class ItemStock {
// 初始化測試庫存量
public static void initKey(String key, String stock){
Jedis jedis = RedisUtil.getResource();
try {
jedis.set(key, stock);
} finally {
if (jedis != null){
jedis.close();
}
}
Long restNum = Long.valueOf((String)RedisUtil.get(key));
System.out.println("init:" + restNum);
}
static String script =
"local num = ARGV[1] \n" +
"local key = KEYS[1] \n" +
"local stock = redis.call('get',key) \n" +
"if stock - num >= 0 \n" +
"then redis.call('decrby',key, num) \n" +
"return 1 \n" + // 成功
"else \n" +
"return 0 \n" + //失敗
"end";
//lua腳本實現扣減庫存
public static void deduct(String key , int num){
Jedis jedis = RedisUtil.getResource();
try {
Object re = jedis.evalsha(jedis.scriptLoad(script), Arrays.asList(key), Arrays.asList(num + ""));
System.out.println("deduct:" + re);
} catch (Exception e){
e.printStackTrace();
} finally {
if (jedis != null){
jedis.close();
}
}
}
// 用於停止扣減庫存主線程
public static void stockStop(String key) {
while (true) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Long restNum = Long.valueOf((String) RedisUtil.get(key));
if (restNum == 1) {
return;
}
}
}
}
會員下單標誌代碼:
package test.cache;
import redis.clients.jedis.Jedis;
public class MemberOrder {
// key -> activityId + itemId
final static String ACTIVITY_ITEM_BIG_MAP_KEY = "%s_%s_BIT_MAP_KEY";
public static Boolean isMarkedStock(Long activityId, Long itemId, Long memberId){
Jedis jedis = RedisUtil.getResource();
try {
return jedis.getbit(String.format(ACTIVITY_ITEM_BIG_MAP_KEY, activityId.toString(), itemId.toString()), memberId);
} finally {
jedis.close();
}
}
public static Boolean markStock(Long activityId, Long itemId, Long memberId, boolean isMarked){
Jedis jedis = RedisUtil.getResource();
try {
return jedis.setbit(
String.format(ACTIVITY_ITEM_BIG_MAP_KEY, activityId.toString(), itemId.toString()),
memberId,
isMarked);
} finally {
jedis.close();
}
}
}
下單業務代碼:
package test.cache;
import java.util.UUID;
import java.util.concurrent.locks.Lock;
public class SecondKillOrder {
// activity_id + item_id + member_id
private final static String LOCK_KEY = "%s_%s_%s";
// activity_id + item_id
public final static String STOCK_KEY = "%s_%s_stock";
// 只允許會員下一單,並且每單3個庫存
public static void orderFlow(Long activityId, Long itemId, Long memberId){
// 校驗會員是否已經存在未過時訂單
if (MemberOrder.isMarkedStock(activityId, itemId, memberId)){
System.out.println("您已經存在秒殺訂單");
}
// 初始化分佈式鎖 lock activity_id, item_id, member_id
String uuid = UUID.randomUUID().toString();
Lock lock = new RedisDistributedLock(
RedisUtil.getPool(),
String.format(LOCK_KEY, activityId, itemId, memberId),
uuid
);
try {
if (!lock.tryLock()){
return;
}
// TODO 訂單或者商品校驗
Thread.sleep(200);
// 扣減庫存
ItemStock.deduct(String.format(STOCK_KEY, activityId, itemId), 3);
// TODO 創建訂單
Thread.sleep(300);
// 標誌會員
MemberOrder.markStock(activityId, itemId, memberId, true);
} catch (Exception e){
e.printStackTrace();
// 回滾會員標誌
MemberOrder.markStock(activityId, itemId, memberId, false);
} finally {
lock.unlock();
}
}
}
測試代碼:
package test.cache;
import java.util.Random;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
public class SecondKillTest {
public static final String STOCK_KEY = SecondKillOrder.STOCK_KEY;
public static final String STOCK_NUM = "1000";
public static final int THREAD_NUM = 200;
// key -> activityId + itemId + memberId
public final static String LOCK_KEY = "%s_%s_%s";
public static void main(String[] args) {
// 扣減庫存測試
// stockTest();
// 分佈式鎖測試
// lockTest();
// 下單流程測試
orderTest();
}
private static void orderTest() {
int i = THREAD_NUM;
Long activityId = 123456789L;
Long itemId = 987456123L;
ItemStock.initKey(String.format(STOCK_KEY, activityId.toString(), itemId.toString()), STOCK_NUM);
batchFunc(countDownLatch -> {
Long memberId = Math.abs(new Random().nextLong() % 10);
System.out.println("memberId:" + memberId);
countDownLatch.await();
SecondKillOrder.orderFlow(activityId, itemId, memberId);
}, THREAD_NUM);
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private static void lockTest() {
batchFunc(countDownLatch -> onceLock(countDownLatch), THREAD_NUM);
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private static void stockTest() {
ItemStock.initKey(STOCK_KEY, STOCK_NUM);
batchFunc((countDownLatch)->{
countDownLatch.await();
ItemStock.deduct(STOCK_KEY,3);
}, THREAD_NUM);
ItemStock.stockStop(STOCK_KEY);
}
// 一次鎖操作
public static void onceLock(CountDownLatch countDownLatch){
final Long ACTIVITY_ID = 123456789L;
final Long ITEM_ID = 12345678901L;
final Long MEMBER_ID = 456123789L;
String uuid = UUID.randomUUID().toString();
RedisDistributedLock lock = new RedisDistributedLock(
RedisUtil.getPool(),
String.format(LOCK_KEY, ACTIVITY_ID, ITEM_ID, MEMBER_ID),
uuid
);
try {
if (countDownLatch != null){
countDownLatch.await();
}
if (!lock.tryLock()){
return;
}
Thread.sleep(200);
System.out.println(Thread.currentThread().getName() + ":get");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
//併發測試代碼
private static void batchFunc(Func func, int threadNum) {
CountDownLatch countDownLatch = new CountDownLatch(threadNum);
for (int i = 0; i < threadNum; i++) {
new Thread(() -> {
try {
func.execute(countDownLatch);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
countDownLatch.countDown();
}
return;
}
@FunctionalInterface
interface Func {
void execute(CountDownLatch countDownLatch) throws InterruptedException;
}
}
redis連接管理類代碼可參考 redis 事務與Lua腳本
以上代碼只是簡單實現了下單流程,還有異步操作流程,補償流程沒有實現。
六.總結
以上爲對服務端的秒殺流程的總結。這裏涉及到的抵禦請求洪流的一般用緩存(redis)。至於如果出現緩存都無法抵禦的請求量的話,面對這種情況就必須預先準備好一些降級和限流措施,或者不想丟失數據的話可以用mq對會員請求進行蓄洪。