秒殺下單流程問題

一.場景

秒殺下單Flow,一個會員,只能下一單。
下圖爲秒殺下單的簡單流程圖,其中除了<異步操作>外,其它操作均爲同步操作。

Created with Raphaël 2.2.0開始下單請求前置校驗成功?扣減庫存成功?創建訂單成功?異步操作返回下單成功結束回滾庫存返回下單失敗返回沒庫存yesnoyesnoyesno

二.問題

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 Long 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.printf("deduct:%d\n", re);

			return (Long) re;
		} catch (Exception e){
			e.printStackTrace();
		} finally {
			if (jedis != null){
				jedis.close();
			}
		}
		return 0L;
	}

	// 用於停止扣減庫存主線程
	public static void stockStop(String key, Integer stopNum) {
		while (true) {

			try {
				Thread.sleep(1000);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}

			Long restNum = Long.valueOf((String) RedisUtil.get(key));

			System.out.printf("restNum:%d,stopNum:%d\n", restNum, stopNum);

			if (restNum.intValue() == stopNum) {
				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, Integer buyNum){
		// 校驗會員是否已經存在未過時訂單
		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);

			// 扣減庫存 + 標誌會員
			if (!cacheOpt(activityId, itemId, memberId, buyNum, true)){
				return;
			}

			// TODO 創建訂單
			Thread.sleep(300);

		} catch (Exception e){
			e.printStackTrace();
			// 回滾
			cacheOpt(activityId, itemId, memberId, -buyNum, false);
		} finally {
			lock.unlock();
		}

	}

	private static boolean cacheOpt(Long activityId, Long itemId, Long memberId, int i, boolean b) {

		Long res = ItemStock.deduct(String.format(STOCK_KEY, activityId, itemId), i);

		if (res == 1){// 庫存扣減成功
			MemberOrder.markStock(activityId, itemId, memberId, b);
			return true;
		} else {// 庫存扣減失敗
			return false;
		}
	}

}

這裏創建訂單和cacheOpt的順序可以適當調整,爲了可以過濾無效重複請求,先創建訂單,那麼需要考慮如果扣減庫存和標誌會員操作後應用掛掉的情況,這時候需要作一些補償工作;爲了確保一致性,先扣減庫存和標誌會員操作,後創建訂單,這樣就避免補償工作,但是在性能方面可能不如前面的方案。

測試代碼:

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;
		Integer buyNum = 3;

		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, buyNum);

		}, 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() {
		String key = String.format(STOCK_KEY, "test", "test");
		ItemStock.initKey(key, STOCK_NUM);
		batchFunc((countDownLatch)->{
			countDownLatch.await();
			ItemStock.deduct(key,3);
		}, THREAD_NUM);
		ItemStock.stockStop(key, Integer.valueOf(STOCK_NUM) - THREAD_NUM*3);
	}

	// 一次鎖操作
	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對會員請求進行蓄洪。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章