秒殺系統-下單解決方案(從0到1)
單機版(不考慮庫存問題):
- 普通下單——不考慮庫存、不考慮超賣、不考慮併發問題,只考慮性能問題。
單機版(考慮庫存問題):
- 程序鎖。
- aop鎖。
- 隊列(blockingQueue)
分佈式:
- 數據庫鎖(悲觀鎖、樂觀鎖)。
- 分佈式鎖。
- 隊列(mq)
庫存控制:
下單操作的時候,不進行庫存控制,出現同一件商品被售賣多次的現象。也就是我們通常所說的超賣現象。(糾正概念:
超賣不是把商品庫存賣成負數,而是同一件商品被賣多次
)
單機版(不考慮庫存問題)
普通下單
不考慮庫存、不考慮超賣、不考慮併發問題,只考慮性能問題。
沒有任何鎖操作,直接減庫存加銷量,直接下單(向訂單庫中直接插入訂單數據——以上操作都是直接操作數據庫)
private void startSubmitOrder() {
//查詢商品信息
seckillGoods.findOneById(商品Id);
//判斷商品是否上架、活動是否開始、庫存有沒有.....
//商品庫存減1、銷量加1,更新商品表
seckillGoods.update();
//保存訂單
order.save();
}
優化:多線程下單
private void startSubmitOrderMultiThread() {
//查詢商品信息
seckillGoods.findOneById(商品Id);
//判斷商品是否上架、活動是否開始、庫存有沒有.....
//商品庫存減1、銷量加1,更新商品表
seckillGoods.update();
//保存訂單(多線程方式下單)
new Thread(() -> {
order.save();
}).start();
}
優化:緩存
活動開始之前把商品信息放入緩存中,查詢商品直接從緩存中獲取。
private void startSubmitOrderCache() {
//從緩存中查詢商品信息
redisTemplate.get(商品Id);
//判斷商品是否上架、活動是否開始、庫存有沒有.....
//商品庫存減1、銷量加1,更新商品表
seckillGoods.update();
//保存訂單(多線程方式下單)
new Thread(() -> {
order.save();
}).start();
}
10000用戶同時下單,理論上庫存還剩0,現庫存還剩113個,說明程序鎖出現超賣現象。
問題:10000個用戶同時下單(他們都認爲自己買到商品,實際上存在嚴重問題),只購買了887個商品。
單機版(考慮庫存問題)
程序鎖
// 定義全局程序鎖(ReentrantLock)
private ReentrantLock reentrantLock = new ReentrantLock();
@Transactional(rollbackFor = Exception.class)
private void startSubmitOrderReentrantLock() {
try {
//加鎖
reentrantLock.lock();
//從緩存中查詢商品信息
redisTemplate.get(商品Id);
//判斷商品是否上架、活動是否開始、庫存有沒有.....
//商品庫存減1、銷量加1,更新商品表
seckillGoods.update();
//保存訂單
order.save();
} catch (Exception e) {
e.printStackTrace();
} finally {
//解鎖
reentrantLock.unlock();
}
}
10000用戶同時下單,理論上庫存還剩0,現庫存還剩281個,說明程序鎖出現超賣現象。
庫存無法控制的原因,和Spring事務有關係:
- 事務回滾原理:方法拋出異常,反之提交。
- 程序鎖:事務提交時間的問題,也就是說鎖釋放了,事務還未提交,導致其它線程讀取到髒數據。
解決方案:
- 鎖上移。
- 手動提交事務。
AOP鎖
利用Spring切面編程模式,實現AOP鎖。
/**
* @Author: LailaiMonkey
* @Description:自定義aop切面註解
* @Date:Created in 2020-12-06 14:19
* @Modified By:
*/
@Target({
ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ServiceLock {
String description() default "";
}
aop攔截器:
/**
* @Author: LailaiMonkey
* @Description:註解攔截
* @Date:Created in 2020-12-06 14:21
* @Modified By:
*/
@Component
@Scope
@Aspect
//order越小越先執行
@Order(1)
public class LockAspect {
private ReentrantLock reentrantLock = new ReentrantLock();
@Around("@annotation(com.monkey.test.sdfsdaf.ServiceLock)")
public Object lockAspect(ProceedingJoinPoint joinPoint) {
//加鎖
Object object = null;
try {
reentrantLock.lock();
object = joinPoint.proceed();
} catch (Throwable e) {
e.printStackTrace();
} finally {
//解鎖
reentrantLock.unlock();
}
return object;
}
}
同普通下單業務邏輯,需要加攔截註解
@Transactional(rollbackFor = Exception.class)
@ServiceLock
private void startSubmitOrder() {
//查詢商品信息
seckillGoods.findOneById(商品Id);
//判斷商品是否上架、活動是否開始、庫存有沒有.....
//商品庫存減1、銷量加1,更新商品表
seckillGoods.update();
//保存訂單
order.save();
}
庫存已經完美控制住!!!
隊列
將下單信息放入隊列,後臺線程慢慢處理訂單業務(訂單異步處理)——blockingQueue
下單——數據放入隊列——隊列消費數據。
定義全局隊列:
/**
* @Author: LailaiMonkey
* @Description:定義全局隊列
* @Date:Created in 2020-12-06 15:02
* @Modified By:
*/
public class SeckillQueue {
private final static BlockingQueue<OrderModel> BLOCKING_QUEUE = new LinkedBlockingDeque<>();
//不可以實例化
private SeckillQueue() {
}
private static class SingletonHolder {
private static SeckillQueue queue = new SeckillQueue();
}
public static SeckillQueue getQueue() {
return SingletonHolder.queue;
}
/**
* 放入隊列
*
* @param model
* @return
*/
public Boolean product(OrderModel model) {
return BLOCKING_QUEUE.offer(model);
}
/**
* 出隊列
*
* @param model
* @return
*/
public OrderModel consumer() throws InterruptedException {
return BLOCKING_QUEUE.take();
}
/**
* 獲得隊列長度
*
* @param model
* @return
*/
public int size() {
return BLOCKING_QUEUE.size();
}
}
隊列消費:
/**
* @Author: LailaiMonkey
* @Description:隊列消費
* @Date:Created in 2020-12-06 15:09
* @Modified By:
*/
@Component
public class TaskRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) throws Exception {
new Thread(() -> {
while (true) {
try {
OrderModel model = SeckillQueue.getQueue().consumer();
if (model != null) {
//商品庫存減1、銷量加1,更新商品表
seckillGoods.update();
//保存訂單
order.save();
}
} catch (Exception ex) {
ex.printStackTrace();
}
}
}).start();
}
}
下單:
@Transactional(rollbackFor = Exception.class)
@ServiceLock
private void startSubmitOrder() {
//搭建下單模型
OrderModel model = new OrderModel();
model.set.......
//放入隊列
Boolean flag = SeckillQueue.getQueue().product(model);
if (flag) {
//通知用戶下單成功
} else {
//秒殺失敗
}
}
隊列大小爲100,此時如果隊列已經滿了,放入隊列的動作就會失敗,也就是意味着下單失敗,此時隊列的大小根據後端業務處理能力進行設置。
使用隊列作爲緩存對象,減輕服務壓力,提升服務吞吐能力。
優化:
- 可以把放入隊列過程改成多線程放入。
缺點:
- BlockingQueue隊列是內存隊列,佔用jvm內存大小,如果隊列太大會和jvm進程搶佔資源,導致性能下降,如果隊列太小,導致下單隊列滿,會出現下單失敗的現象。
- 吞吐能力問題
- 分佈式情況下無法控制庫存
解決方案:
- 分佈式庫存、分佈式部署、分佈式隊列
分佈式
數據庫悲觀鎖
查詢商品庫存加for update鎖定該商品。
@Transactional(rollbackFor = Exception.class)
private void startSubmitOrderBySqlLock() {
//查詢商品信息(select .... for update)
seckillGoods.findOneByIdSqlLock(商品Id);
//判斷商品是否上架、活動是否開始、庫存有沒有.....
//商品庫存減1、銷量加1,更新商品表
seckillGoods.update();
//保存訂單
order.save();
}
庫存控制沒有問題,性能差
數據庫樂觀鎖
在商品表加一個version字段,當多線程(併發)查詢修改時候就可以進行版本比對數據修改,防止數據進行髒讀。
@Transactional(rollbackFor = Exception.class)
private void startSubmitOrderBySqlLock() {
//查詢商品信息
seckillGoods.findOneById(商品Id);
//判斷商品是否上架、活動是否開始、庫存有沒有.....
//商品庫存減1、銷量加1,更新商品表,判斷數據庫version和查詢version是否一樣
//update ..... where version = 查詢商品的version
int flag = seckillGoods.update();
//樂觀鎖更新成功後方可繼續操作
if (flag > 0) {
//保存訂單
order.save();
} else {
//提示太火爆了
}
}
由於數據庫鎖進行操作磁盤數據,性能消耗比較高,因此可以使用內存鎖提高下單的性能。
分佈式鎖
@Transactional(rollbackFor = Exception.class)
private void startSubmitOrderReentrantLock() {
boolean result = false;
try {
//使用redisson加鎖,嘗試等待3s,上鎖後20s自動解除
result = redisson.tryLock("seckill_goods_lock" + 商品Id, TimeUnit.SECONDS, 3, 10);
if (result) {
//從緩存中查詢商品信息
redisTemplate.get(商品Id);
//判斷商品是否上架、活動是否開始、庫存有沒有.....
//商品庫存減1、銷量加1,更新商品表
seckillGoods.update();
//保存訂單
order.save();
} else {
//提示太火爆
}
} catch (Exception e) {
e.printStackTrace();
} finally {
//解鎖
if (result) {
redisson.unlock("seckill_goods_lock" + 商品Id);
}
}
}
redis鎖還是會和本地事務出現衝突,但是由於操作reids是遠程操作,通過網絡io進行,有網絡延遲,因此基本不會出現庫存控制失敗的現象。redis在數據進行操作是異步的,延遲的,因些當數據事務提交後redis才釋放,所以這樣沒問題。
確保100%沒有問題需要redis升級爲aop鎖,把上述aop鎖ReentrantLock換成redis的lock即可。
mq
同上,把blockingQueue換成rabbitMq即可。
思考
項目使用分佈式部署,數據一致性處理非常困難:
- 網絡拉動
- 網絡延遲
- 服務宕機
- 機器宕機
- 程序崩潰
- 異常
以上問題必須然會發生,所以在處理數據一致性方面必有取捨,強一致性(基於數據庫)——CAP(分佈式)、Base理論(最終一致性)