java 用redis如何處理電商平臺,秒殺、搶購超賣
一、剛來公司時間不長,看到公司原來的同事寫了這樣一段程式碼,下面貼出來:
1、這是在一個方法呼叫下面程式碼的部分:
if (!this.checkSoldCountByRedisDate(key, limitCount, buyCount, endDate)) {// 標註10:
throw new ServiceException("您購買的商品【" + commodityTitle + "】,數量已達到活動限購量");
}
2、下面是判斷超賣的方法:
/** 根據快取資料查詢是否賣超 */ //標註:1;synchronized private synchronized boolean checkSoldCountByRedisDate(String key, int limitCount, int buyCount, Date endDate) { boolean flag = false; if (redisUtil.exists(key)) {//標註:2;redisUtil.exists(key) Integer soldCount = (int) redisUtil.get(key);//標註:3;redisUtil.get(key) Integer totalSoldCount = soldCount + buyCount; if (limitCount > (totalSoldCount)) { flag = false;//標註:4;flag = false } else { if (redisUtil.tryLock(key, 80)) {//標註:5;rdisUtil.tryLock(key, 80) redisUtil.remove(key);// 解鎖 //標註:6;redisUtil.remove(key) redisUtil.set(key, totalSoldCount);//標註:7;redisUtil.set(key, totalSoldCount) flag = true; } else { throw new ServiceException("活動太火爆啦,請稍後重試"); } } } else { //標註:8;redisUtil.set(key, new String("buyCount"), DateUtil.diffDateTime(endDate, new Date())) redisUtil.set(key, new String("buyCount"), DateUtil.diffDateTime(endDate, new Date())); flag = false; } return flag; }
3、上面提到的redisUtil類中的方法,其中redisTemplate為org.springframework.data.redis.core.RedisTemplate;這個不瞭解的可以去網上找下,spring-data-redis.jar的相關文件,貼出來redisUtil用到的相關方法:
4、上面提到的DateUtil類,我會在下面用檔案的形式發出來!/** * 判斷快取中是否有對應的value * * @param key * @return */ public boolean exists(final String key) { return redisTemplate.hasKey(key); } /** * 將鍵值對設定一個指定的時間timeout. * * @param key * @param timeout * 鍵值對快取的時間,單位是毫秒 * @return 設定成功返回true,否則返回false */ public boolean tryLock(String key, long timeout) { boolean isSuccess = redisTemplate.opsForValue().setIfAbsent(key, ""); if (isSuccess) {//標註:9;redisTemplate.expire redisTemplate.expire(key, timeout, TimeUnit.MILLISECONDS); } return isSuccess; } /** * 讀取快取 * * @param key * @return */ public Object get(final String key) { Object result = null; ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue(); result = operations.get(key); return result; } /** * 刪除對應的value * * @param key */ public void remove(final String key) { if (exists(key)) { redisTemplate.delete(key); } } /** * 寫入快取 * * @param key * @param value * @return */ public boolean set(final String key, Object value) { return set(key, value, null); } /** * * @Title: set * @Description: 寫入快取帶有效期 * @param key * @param value * @param expireTime * @return boolean 返回型別 * @throws */ public boolean set(final String key, Object value, Long expireTime) { boolean result = false; try { ValueOperations<Serializable, Object> operations = redisTemplate.opsForValue(); operations.set(key, value); if (expireTime != null) { redisTemplate.expire(key, expireTime, TimeUnit.SECONDS); } result = true; } catch (Exception e) { e.printStackTrace(); } return result; }
二、現在我們來解讀下這段程式碼,看看作者的意圖,以及問題點在什麼地方,這樣幫助更多的人瞭解,在電商平臺如何處理在搶購、秒殺時出現的超賣的情況處理
1、引數說明,上面checkSoldCountByRedisDate方法,有4個引數分別是:
key:購買數量的計數,放於redis快取中的key;
limitCount:查詢原始碼發現,原註釋為:總限購數量;
buyCount:為當前一次請求下單要購買的數量;
endDate:活動結束時間;
2、通過上面的標註,我們來解析原作者的意圖:
標註1:想通過synchronized關鍵字實現同步,看似沒問題
標註2:通過redisUtil.exists方法判斷key是否存在,看似沒什麼問題
標註3:redisUtil.get(key)獲取購買總數,似乎也沒問題
標註4:當用戶總購買數量<總限購量返回false,看起來只是一個簡單的判斷
標註5:想通過redisUtil.tryLock加鎖,實現超賣的處理,後面的程式碼實現計數,好像也沒什麼問題
標註6:標註5加了鎖,那麼通過redisUtil.remove解鎖,看起來順理成章
標註7:通過redisUtil.set來記錄使用者購買量,原作者應該是這個意思了
標註8:如果標註2判斷的key不存在,在這裡建立一個key,看起來程式碼好像也是要這麼寫
標註9:我想原作者是不想出現死鎖,用redisTemplate.expire做鎖超時的方式來解除死鎖,這樣是可以的
3、針對上面作者意圖的分析,我們來看下,看似沒有問題的,是否真的就是沒問題!呵呵。。,好賤!
下面看看每個標註,可能會出現的問題:
標註1:synchronized關鍵字,在分散式高併發的情況下,不能實現同步處理,不信測試下就知道了;
那麼就可能會出現 的問題是:
現在同一使用者發起請A、B或不同使用者發起請求A、B,會同時進入checkSoldCountByRedisDate方法並執行
標註2:當搶購開始時,A、B請求同時率先搶購,進入checkSoldCountByRedisDate方法,
A、B請求被redisUtil.exists方法判斷key不存在,
從而執行了標註8的部分,同時去執行一個建立key的動作;
真的是好坑啊!第一個開始搶購都搶不到!
標註3:當請求A、B同時到達時,假設:請求A、B當前購買buyCount引數為40,標註3得到的soldCount=50,limitCount=100,
此時請求A、B得到的totalSoldCount均為90,問題又來了
標註4:limitCount > (totalSoldCount):totalSoldCount=90,limitCount=100,些時flag就等於 false,
返回給標註10的位置丟擲異常資訊(throw new ServiceException("您購買的商品【" + commodityTitle + "】,數量已達到活動限購量"););
請求A、B都沒搶到商品。什麼鬼?總共購買90,總限購量是100,這就丟擲異常達到活動限購數,我開始看不懂了
標註5:在這裡加鎖的時候,如果當執行到標註9:isSuccess=true,客戶端中斷,不執行標註9以後的程式碼,
完蛋,死鎖出現了!誰都別想搶到
下面我們假設A請求比B請求稍慢一點兒到達時,A、B請求的buyCount引數為40,標註3得到的soldCount=50、limitCount=100去執行的else裡面的程式碼,
也就checkSoldCountByRedisDate方法中的:
else {
if (redisUtil.tryLock(key, 80)) {
redisUtil.remove(key);// 解鎖
redisUtil.set(key, totalSoldCount);
flag = true;
} else {
throw new ServiceException("活動太火爆啦,請稍後重試");
}
}
標註6、7:A請求先到達,假設加鎖成功,併成功釋放鎖,設定的key的值為90後,這裡B請求也加鎖成功,釋放鎖成功,設定key的值為90,
那麼問題來了:
A、B各買40,原購買數為50,總限量數為100,40+40+50=130,大於最大限量數卻成功執行,我了個去,公司怎麼向客戶交代!
凌晨了,廢話不多說了,關鍵還要看問題怎麼處理,直接上程式碼吧!呼叫的地方就不看了,其實,程式碼也沒幾行,有註釋大家一看就明白了:
/**
*
* 雷------2016年6月17日
*
* @Title: checkSoldCountByRedisDate
* @Description: 搶購的計數處理(用於處理超賣)
* @param @param key 購買計數的key
* @param @param limitCount 總的限購數量
* @param @param buyCount 當前購買數量
* @param @param endDate 搶購結束時間
* @param @param lock 鎖的名稱與unDieLock方法的lock相同
* @param @param expire 鎖佔有的時長(毫秒)
* @param @return 設定檔案
* @return boolean 返回型別
* @throws
*/
private boolean checkSoldCountByRedisDate(String key, int limitCount, int buyCount, Date endDate, String lock, int expire) {
boolean check = false;
if (this.lock(lock, expire)) {
Integer soldCount = (Integer) redisUtil.get(key);
Integer totalSoldCount = (soldCount == null ? 0 : soldCount) + buyCount;
if (totalSoldCount <= limitCount) {
redisUtil.set(key, totalSoldCount, DateUtil.diffDateTime(endDate, new Date()));
check = true;
}
redisUtil.remove(lock);
} else {
if (this.unDieLock(lock)) {
logger.info("解決了出現的死鎖");
} else {
throw new ServiceException("活動太火爆啦,請稍後重試");
}
}
return check;
}
/**
*
* 雷------2016年6月17日
*
* @Title: lock
* @Description: 加鎖機制
* @param @param lock 鎖的名稱
* @param @param expire 鎖佔有的時長(毫秒)
* @param @return 設定檔案
* @return Boolean 返回型別
* @throws
*/
@SuppressWarnings("unchecked")
public Boolean lock(final String lock, final int expire) {
return (Boolean) redisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
boolean locked = false;
byte[] lockValue = redisTemplate.getValueSerializer().serialize(DateUtil.getDateAddMillSecond(null, expire));
byte[] lockName = redisTemplate.getStringSerializer().serialize(lock);
locked = connection.setNX(lockName, lockValue);
if (locked)
connection.expire(lockName, TimeoutUtils.toSeconds(expire, TimeUnit.MILLISECONDS));
return locked;
}
});
}
/**
*
* 雷------2016年6月17日
*
* @Title: unDieLock
* @Description: 處理髮生的死鎖
* @param @param lock 是鎖的名稱
* @param @return 設定檔案
* @return Boolean 返回型別
* @throws
*/
@SuppressWarnings("unchecked")
public Boolean unDieLock(final String lock) {
boolean unLock = false;
Date lockValue = (Date) redisTemplate.opsForValue().get(lock);
if (lockValue != null && lockValue.getTime() <= (new Date().getTime())) {
redisTemplate.delete(lock);
unLock = true;
}
return unLock;
}
下面會把上面方法中用到的相關DateUtil類的方法貼出來:/**
* 日期相減(返回秒值)
* @param date Date
* @param date1 Date
* @return int
* @author
*/
public static Long diffDateTime(Date date, Date date1) {
return (Long) ((getMillis(date) - getMillis(date1))/1000);
}
public static long getMillis(Date date) {
Calendar c = Calendar.getInstance();
c.setTime(date);
return c.getTimeInMillis();
}
/**
* 獲取 指定日期 後 指定毫秒後的 Date
*
* @param date
* @param millSecond
* @return
*/
public static Date getDateAddMillSecond(Date date, int millSecond) {
Calendar cal = Calendar.getInstance();
if (null != date) {// 沒有 就取當前時間
cal.setTime(date);
}
cal.add(Calendar.MILLISECOND, millSecond);
return cal.getTime();
}
到這裡就結束!
新補充:
import java.util.Calendar;
import java.util.Date;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.TimeoutUtils;
import org.springframework.stereotype.Component;
import cn.mindmedia.jeemind.framework.utils.redis.RedisUtils;
import cn.mindmedia.jeemind.utils.DateUtils;
/**
* @ClassName: LockRetry
* @Description: 此功能只用於促銷組
* @author 雷
* @date 2017年7月29日 上午11:54:54
*
*/
@SuppressWarnings("rawtypes")
@Component("lockRetry")
public class LockRetry {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private RedisTemplate redisTemplate;
/**
*
* @Title: retry
* @Description: 重入鎖
* @author 雷
* @param @param lock 名稱
* @param @param expire 鎖定時長(秒),建議10秒內
* @param @param num 取鎖重試試數,建議不大於3
* @param @param interval 重試時長
* @param @param forceLock 強制取鎖,不建議;
* @param @return
* @param @throws Exception 設定檔案
* @return Boolean 返回型別
* @throws
*/
@SuppressWarnings("unchecked")
public Boolean retryLock(final String lock, final int expire, final int num, final long interval, final boolean forceLock) throws Exception {
Date lockValue = (Date) redisTemplate.opsForValue().get(lock);
if (forceLock) {
RedisUtils.remove(lock);
}
if (num <= 0) {
if (null != lockValue && lockValue.getTime() >= (new Date().getTime())) {
logger.debug(String.valueOf((lockValue.getTime() - new Date().getTime())));
Thread.sleep(lockValue.getTime() - new Date().getTime());
RedisUtils.remove(lock);
return retryLock(lock, expire, 1, interval, forceLock);
}
return false;
} else {
return (Boolean) redisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
boolean locked = false;
byte[] lockValue = redisTemplate.getValueSerializer().serialize(DateUtils.getDateAdd(null, expire, Calendar.SECOND));
byte[] lockName = redisTemplate.getStringSerializer().serialize(lock);
logger.debug(lockValue.toString());
locked = connection.setNX(lockName, lockValue);
if (locked)
return connection.expire(lockName, TimeoutUtils.toSeconds(expire, TimeUnit.SECONDS));
else {
try {
Thread.sleep(interval);
return retryLock(lock, expire, num - 1, interval, forceLock);
} catch (Exception e) {
e.printStackTrace();
return locked;
}
}
}
});
}
}
}
/**
*
* @Title: getDateAddMillSecond
* @Description: (TODO)取將來時間
* @author 雷
* @param @param date
* @param @param millSecond
* @param @return 設定檔案
* @return Date 返回型別
* @throws
*/
public static Date getDateAdd(Date date, int expire, int idate) {
Calendar calendar = Calendar.getInstance();
if (null != date) {// 預設當前時間
calendar.setTime(date);
}
calendar.add(idate, expire);
return calendar.getTime();
}
/**
* 刪除對應的value
* @param key
*/
public static void remove(final String key) {
if (exists(key)) {
redisTemplate.delete(key);
}
}
/**
* 判斷快取中是否有對應的value
* @param key
* @return
*/
public static boolean exists(final String key) {
return stringRedisTemplate.hasKey(key);
}
private static StringRedisTemplate stringRedisTemplate = ((StringRedisTemplate) SpringContextHolder.getBean("stringRedisTemplate"));