從數據安全的角度解決高併發下秒殺問題(以及redis連接池併發下問題)

從數據安全的角度解決高併發下秒殺問題

這個是博主自己閒的無聊的時候想寫個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超時報錯。

[外鏈圖片轉存失敗(img-sGlr5oIQ-1564485728541)(從數據安全的角度解決高併發下秒殺問題.assets/20161111112234934.jpg)]

歸還的時候,會判斷是不是超過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的數量

在這裏插入圖片描述

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