從數據安全的角度解決高併發下秒殺問題
這個是博主自己閒的無聊的時候想寫個demo玩玩,並不算最好的解決方案,當然也是閹割了很多東西的簡化版,現實業務肯定比這個複雜
這裏的話不考慮高併發帶來的性能問題,什麼負載均衡,限流,分發請求等。就考慮如何保證商品的庫存不會超賣。
一、思路
秒殺下的數據安全,無非是兩個角度,一是商品庫存,不能超賣;二是用戶秒殺,一般來說都不會允許同一個用戶秒殺兩次
先簡單說說思路吧。
-
首先肯定是需要用到萬能的redis來處理,因爲他的單線程處理,操作都是原子性的,數據安全也通過redis控制
-
首先是將數據庫參加秒殺的商品,根據定時任務提前將庫存數放到redis。怎麼放呢?我考慮將庫存數遍歷放到redis的隊列中去,秒殺成功就pop一個出來,等pop操作拿不到的時候就是售空了。還有一種方式是使用decr命令,先set庫存數量到redis,每次秒殺都decr原子性減一,並返回計算後的值,判斷這個值是不是小於0
-
至於用戶重複秒殺的問題,也是同理,使用redis,redis有一個
setnx
命令,用處就是調用的時候,如果發現有這個key,就返回false或者-1,如果沒有這個key就返回true或者0。在用戶秒殺之前,將用戶的userId拼接成一個key,先調用這個命令,如果返回false說明這個key已經操作過了,直接返回false,不可重複操作 -
從上面可以看出,使用的redis命令不僅僅是因爲本身具備原子性操作的特點,還有一個就是“讀寫”也是原子性的,寫一個值的同時返回一個值告訴我們結果,這確保了在java代碼中也是原子性的
使用到的工具
- redis
- 谷歌的cache緩存類
- AtomicInteger
- SpringBoot測試類
- idea的console grep插件,可以篩選控制檯的輸出
二、代碼
直接看代碼是最直觀的:
測試類初始化方法,也就是提前將庫存放到redis那段
package com.zgd.demo.thread.miaosha;
import com.zgd.demo.thread.ThreadPoolBuilder;
import com.zgd.demo.thread.test.MyBaseTest;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomUtils;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @Author: zgd
* @Date: 2019/5/8 16:30
* @Description:
*/
@Slf4j
public class MiaoShaTest extends MyBaseTest {
@Autowired
private MiaoShaServiceImpl miaoShaService;
@Autowired
private MiaoShaInitServiceImpl miaoShaInitService;
@Test
public void fun01() throws InterruptedException {
//模擬商品id
long goodsId = 12345L;
log.info("初始化庫存");
miaoShaInitService.initStock(goodsId, 100);
}
}
初始化庫存
package com.zgd.demo.thread.miaosha;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import java.util.Set;
/**
* MiaoShaInitServiceImpl
*
* @author zgd
* @date 2019/7/30 16:59
*/
@Service
public class MiaoShaInitServiceImpl {
public final static String REDIS_STOCK_COUNT_KEY = "MIAOSHA:STOCK:GOODS_COUNT:";
//活動持續30分鐘
public final static int MIAO_SHA_TIME_OUT = 60 * 30;
@Autowired
private JedisPool jedisPool;
public void initStock(long goodsId, int stock) {
Jedis jedis = jedisPool.getResource();
//刪除上次的測試數據
Set<String> keys = jedis.keys("MIAOSHA:*");
if (! CollectionUtils.isEmpty(keys)){
jedis.del(keys.toArray(new String[keys.size()]));
}
String key = REDIS_STOCK_COUNT_KEY + goodsId;
while (stock > 0) {
jedis.rpush(key, "1");
stock--;
}
jedis.expire(key, MIAO_SHA_TIME_OUT);
}
}
測試秒殺併發類
package com.zgd.demo.thread.miaosha;
import com.zgd.demo.thread.ThreadPoolBuilder;
import com.zgd.demo.thread.test.MyBaseTest;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.RandomUtils;
import org.junit.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @Author: zgd
* @Date: 2019/5/8 16:30
* @Description:
*/
@Slf4j
public class MiaoShaTest extends MyBaseTest {
@Autowired
private MiaoShaServiceImpl miaoShaService;
@Autowired
private MiaoShaInitServiceImpl miaoShaInitService;
@Test
public void fun02() throws InterruptedException {
//庫存100個,併發5w個
int threadCount = 5000;
AtomicInteger success = new AtomicInteger();
ThreadPoolExecutor pool = ThreadPoolBuilder.builder().core(threadCount).max(threadCount).build().get();
CountDownLatch cd = new CountDownLatch(1);
for (int i = 0; i < threadCount; i++) {
//模擬100到500之間的userId,也就是模擬400個用戶
int userId = RandomUtils.nextInt(100, 500);
pool.execute(
() -> {
try {
cd.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int j = 0; j < 10; j++) {
boolean b = miaoShaService.miaoshaList(goodsId, String.valueOf(userId));
if (b) {
success.getAndIncrement();
}
}
}
);
}
cd.countDown();
log.info("--------------開始測試--------------");
pool.shutdown();
while (! pool.awaitTermination(3, TimeUnit.SECONDS)){
log.info("等待執行完畢");
Thread.sleep(1000);
}
System.out.println("success = " + success.get());
}
}
秒殺業務類
package com.zgd.demo.thread.miaosha;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static com.zgd.demo.thread.miaosha.MiaoShaInitServiceImpl.MIAO_SHA_TIME_OUT;
import static com.zgd.demo.thread.miaosha.MiaoShaInitServiceImpl.REDIS_STOCK_COUNT_KEY;
/**
* @Author: zgd
* @Date: 2019/5/7 18:04
* @Description:
*/
@Component
@Slf4j
public class MiaoShaServiceImpl {
/**
* 進行了一次秒殺操作的userId
*/
private final static String REDIS_ORDER_USERID_KEY = "MIAOSHA:ORDER:USER_ID:";
/**
* 秒殺成功的goodsId和userId
*/
private final static String REDIS_ORDER_KEY = "MIAOSHA:ORDER:SUCCESS:";
/**
* 庫存是否售空
*/
private Cache<Long, Boolean> stockOut = CacheBuilder.newBuilder().expireAfterAccess(MIAO_SHA_TIME_OUT, TimeUnit.SECONDS).build();
@Autowired
private JedisPool jedisPool;
/**
* key是商品id,value是userId_創建order的時間戳
*/
private Cache<String, String> orderCache = CacheBuilder.newBuilder().expireAfterAccess(MIAO_SHA_TIME_OUT, TimeUnit.SECONDS).build();
/**
* 統計獲取redis時長較久的次數
*/
private AtomicInteger redisLongTimeCount = new AtomicInteger(0);
/**
* 統計庫存爲0時,查詢內存cache攔截的請求的數量
*/
private AtomicInteger jvmStockOutCount = new AtomicInteger(0);
/**
* 統計庫存爲0時,查詢redis攔截的請求的數量
*/
private AtomicInteger redisStockOutCount = new AtomicInteger(0);
/**
* 統計重複下單時,查詢內存cache攔截的請求的數量
*/
private AtomicInteger jvmRepeatOrderCount = new AtomicInteger(0);
/**
* 統計重複下單時,查詢redis攔截的請求的數量
*/
private AtomicInteger redisRepeatOrderCount = new AtomicInteger(0);
/**
* 獲取redis失敗數量
*/
private AtomicInteger redisFailGetCount = new AtomicInteger(0);
/**
* 在執行秒殺業務之前,先從內存中判斷兩個事:
* 1. 判斷是否售空
* 2. 判斷該用戶是否已秒殺過
* 然後創建redis連接,再一次從redis角度判斷:
* 1. 判斷是否售空
* 2. 判斷該用戶是否已秒殺過\
* 最後再執行秒殺邏輯,比如秒殺成功的用戶緩存到redis,放到隊列中異步處理訂單生成等
* @param goodsId
* @param userId
* @return
*/
public boolean miaoshaList(long goodsId, String userId) {
//判斷庫存
if (isCanMiaoSha(goodsId, userId)) {
return false;
}
Jedis jedis = null;
long l = 0;
try {
//獲取redis連接
jedis = getRedis();
l = System.currentTimeMillis();
if (jedis == null){
return false;
}
//判斷用戶是否秒殺過
if (isUserAlreadyMiaoSha(goodsId, userId, jedis)) {
return false;
}
//判斷是否售空
if (isSellOut(goodsId, userId,jedis)) {
return false;
}
//緩存這個秒殺成功的訂單
jedis.lpush(REDIS_ORDER_KEY+":"+goodsId,userId);
log.info("秒殺成功");
} finally {
log.info("使用redis耗時:{}", (System.currentTimeMillis() - l));
returnJedisResouce(jedis);
}
return true;
}
/**
* 獲取redis
* @return
*/
private Jedis getRedis() {
long s = System.currentTimeMillis();
Jedis jedis;
try {
jedis = jedisPool.getResource();
}catch (Exception e){
int i = redisFailGetCount.addAndGet(1);
log.error("獲取jedis異常: {}\t{}次",e.getMessage()+":"+e.getCause().getMessage(),i);
return null;
} finally {
long hs = System.currentTimeMillis() - s;
if (hs > 2000) {
log.info("獲取jedis 耗時:{}\t總數:{}", hs,redisLongTimeCount.incrementAndGet());
}
}
return jedis;
}
/**
* 先從內存緩存狀態判斷是否可以秒殺,售空或者已經秒殺成功則直接結束
* @param goodsId
* @param userId
* @return
*/
private boolean isCanMiaoSha(long goodsId, String userId) {
Boolean out;
if ((out = stockOut.getIfPresent(goodsId)) != null && out) {
log.info("查詢內存已售空~ 總數:{}",jvmStockOutCount.incrementAndGet());
return true;
}
//查詢是否重複秒殺
if (orderCache.getIfPresent(goodsId + userId) != null) {
log.warn("內存查詢,已秒殺成功,請勿重複操作: userId:{},總數:{}", userId,jvmRepeatOrderCount.incrementAndGet());
return true;
}
return false;
}
/**
* 判斷用戶是否秒殺過
* @param goodsId
* @param userId
* @param jedis
* @return
*/
private boolean isUserAlreadyMiaoSha(long goodsId, String userId, Jedis jedis) {
String orderKey = REDIS_ORDER_USERID_KEY + goodsId+":"+userId;
//緩存這個秒殺的用戶,setnx 返回1表示用戶沒秒殺過。返回0,表示秒殺過,直接結束,來保證一個用戶只秒殺一次
if (jedis.setnx(orderKey,"") == 0L){
orderCache.put(goodsId + userId ,"");
log.warn("redis已秒殺成功,請勿重複操作 userId:{},總數:{}",userId,redisRepeatOrderCount.incrementAndGet());
return true;
}
//給這個key設置過期時間
jedis.setex(orderKey, MIAO_SHA_TIME_OUT, String.valueOf(goodsId));
return false;
}
/**
* 判斷是否售空
* @param goodsId
* @param userId
* @param jedis
* @return
*/
private boolean isSellOut(long goodsId, String userId,Jedis jedis) {
String key = REDIS_STOCK_COUNT_KEY + goodsId;
//從redis隊列彈出一個庫存
String sn = jedis.lpop(key);
if (StringUtils.isEmpty(sn)) {
//更新庫存狀態
log.warn("查詢redis已售空! userId:{}\t總數:{}",userId,redisStockOutCount.incrementAndGet());
stockOut.put(goodsId, true);
return true;
}
return false;
}
/**
* 將redis連接放回連接池中
* @param jedis
*/
private void returnJedisResouce(Jedis jedis) {
if (null != jedis) {
jedis.close();
}
}
}
三、結果
實現5000個線程,每個線程遍歷10次操作,隨機100到500之間的userId,也就是模擬了下400個用戶在近似5w併發下,對100個庫存的商品進行秒殺
查看redis,秒殺成功的key
絕對的數據安全。並切userId,也就是這個key對應的value,肯定都是不重複的。
四、優化和問題
這個代碼進行了一點針對redis的優化:
即使redis能輕鬆應對高併發,但是不代表我們可以有事沒事都找redis,比如商品售空狀態,用戶是否秒殺過,這些數據就是0和1,不大,但是要考慮到網絡延遲等問題,和redis串行處理請求,這些微乎其微的狀態數據是可以放在內存中的,也不用擔心oom。可以看統計的數據:
光就判斷是否售空的狀態,放在內存中的判斷就幫我們省去請求redis近4.5w次,佔了請求9成。
第二個需要注意的問題是,使用jedis連接池完了,要及時將連接資源返回。這個只要是使用到連接池,就需要注意到這個問題。
比如我將returnJedisResouce
方法中的jedis.close();
註釋掉,就會有2200多次獲取jedis失敗的異常:redis.clients.jedis.exceptions.JedisException:Could not get a resource from the pool:Timeout waiting for idle object
其實在測試這個demo的時候,大部分時間我都花在解決jedis連接池高併發的問題上了。
redis默認連接數是1w,我們設置的併發是5w,不調整redis配置的話,就會出現連接池的連接數不夠用,新的線程執行在等待jedis連接池的maxWait
時間後還是拿不到連接,就會報錯。
這是我摸索了測試了半天的配置,沒有出現過拿不到連接的異常。
spring:
redis:
database: 0
port: 6379
host: localhost
password: zhangguodong
timeout: 2000 #Connection timeout.
#jedis連接池
jedis:
pool:
max-wait: 3000ms
#最小空閒數量
min-idle: 10
#最大空閒數量
max-idle: 200
#最大連接數量
max-active: 200
既然是因爲連接池的連接數不夠導致的報錯,那是不是隻要把連接數無腦增大就可以了?
並不是,我這裏雖然併發5w,但是max-active
只配置了200,往300以上走或者50以下都會有上千的報錯
設置max-active爲16,報錯2400條,異常是redis.clients.jedis.exceptions.JedisException:Could not get a resource from the pool:Timeout waiting for idle object
設置max-active爲10000,報錯2000條,但是這次異常是redis.clients.jedis.exceptions.JedisConnectionException:Could not get a resource from the pool:java.net.SocketTimeoutException: connect timed out
,也就是因爲redis是單線程處理,就算你擴大連接池的連接數,redis沒辦法一下子建立這麼多連接處理,也就報了這個建立連接超時的異常。如下圖
在查了各種資料後,發現自己還是對連接池的配置不熟悉。
JedisPoolConfig config = new JedisPoolConfig();
//連接耗盡時是否阻塞, false報異常,ture阻塞直到超時, 默認true
config.setBlockWhenExhausted(true);
//最大連接數,默認8個
config.setMaxTotal(8)
//最大空閒連接數, 默認8個
config.setMaxIdle(8);
//最大連接數, 默認8個
config.setMaxTotal(8);
//獲取連接時的最大等待毫秒數(如果設置爲阻塞時BlockWhenExhausted),如果超時就拋異常, 小於零:阻塞不確定的時間, 默認-1
config.setMaxWaitMillis(-1);
//逐出連接的最小空閒時間 默認1800000毫秒(30分鐘)
config.setMinEvictableIdleTimeMillis(1800000);
//最小空閒連接數, 默認0
config.setMinIdle(0);
//每次逐出檢查時 逐出的最大數目 如果爲負數就是 : 1/abs(n), 默認3
config.setNumTestsPerEvictionRun(3);
//對象空閒多久後逐出, 當空閒時間>該值 且 空閒連接>最大空閒數 時直接逐出,不再根據MinEvictableIdleTimeMillis判斷 (默認逐出策略)
config.setSoftMinEvictableIdleTimeMillis(1800000);
//在獲取連接的時候檢查有效性, 默認false
config.setTestOnBorrow(false);
//在空閒時檢查有效性, 默認false
config.setTestWhileIdle(false);
//逐出掃描的時間間隔(毫秒) 如果爲負數,則不運行逐出線程, 默認-1
config.setTimeBetweenEvictionRunsMillis(-1);
首先是maxTotal,maxIdle和minIdle的關係,連接池會維持minIdle個連接,類似線程池,當要從連接池拿連接時,如果沒超過maxTotal,有沒有空閒連接,就會創建新的。如果超過了,則會默認阻塞等待空閒連接,直到MaxWaitMillis超時報錯。
歸還的時候,會判斷是不是超過MaxIdle,是的話直接銷燬。所以minIdel和maxIdle之間就是一個彈性範圍,所以如果我們配置:
jedis:
pool:
max-wait: 3000ms
#最小空閒數量
min-idle: 10
#最大空閒數量
max-idle: 10
#最大連接數量
max-active: 10000
那麼不管max-active設置多大,用過一次後發現連接池中大於10個,立即銷燬。所以達不到連接池複用的效果,也就是相當於每次都需要創建新的,併發情況下很容易超過等待時間MaxWaitMillis,報錯(如果max-active沒超過200,報的redis.clients.jedis.exceptions.JedisException:Could not get a resource from the pool:Timeout waiting for idle object
,max-active越大,報的是網絡錯誤越多redis.clients.jedis.exceptions.JedisConnectionException:Could not get a resource from the pool:java.net.SocketTimeoutException: connect timed out
)
既然瞭解redis連接數的配置,也就是說明max-active並不是越多越好,這對大多數連接池都適用,redis處理這種簡單的set效率很高,沒必要設置大量的連接數,於是我設置200,沒有報錯了。(因爲demo的測試場景簡單,我把三個值都設置成一樣,也就是類似一個固定大小的連接池)
可以使用info clients
命令查看連接數,如果在程序後面寫個while循環不結束的話,可以看到連接數一直保持maxIdle的數量