Redis分散式鎖在業務場景中的應用
背景描述:
公司是做網際網路借貸業務的,前段時間對接了一個第三方平臺,為該平臺的使用者提供現金借貸業務。但是,剛上線便發現存在一個很嚴重的問題,就是在短時間內(毫秒級)同一個使用者生成了多筆借款,我們的業務場景是要求同一個使用者針對同一類借款,只可以存在一筆待還借款。經過排查發現第三方平臺每次都會在同一時間傳送多次相同的借款請求(其實就是第三方平臺沒有控制好邏輯,極短時間內對使用者的同一個借款請求多次重試引起的)。查出問題後便立即通知第三方平臺排查問題,防止使用者的重複提交,並控制好伺服器重試機制的頻率,但是顯然我們平臺的安全性不能夠依賴第三方去實現。
針對該問題,想到了三個解決辦法:
1. 在應用中通過快取實現一個類似鎖的功能
2. 在資料庫中實現分散式鎖
3. 使用redis實現分散式鎖
第一種方式,在應用中通過快取來實現,將會佔用應用伺服器的大量快取,而且在分散式部署的場景下,顯然無法解決該問題。
第二種方式,資料庫中儲存借款鎖,此方法可行,但是關係型資料庫的效能始終是個問題,當請求量大了之後將會成為系統的瓶頸。
經過考量,最終選擇redis分散式鎖來實現借款邏輯的序列處理。
程式碼實現(敏感資訊處理過,日誌列印也剔除,簡化程式碼):
/**
* @title使用者借款序列執行,redis鎖控制,利用spring AOP 實現
*/
@Aspect
@Order(1)
@Component
public classCustBorrowLockAspect extends WriteResult{
final Loggerlogger = LoggerFactory.getLogger(this.getClass());
final DateFormatformat=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
final long LOCK_MINUTES= 5L; //借款鎖過期時間,5分鐘
final
long EXPIRE_SECOND= TimeUnit.MINUTES
@Resource
private RedisLockHelperlockHelper;
/**
* 提供給第三方借款的介面,方法appLoan4Channel是借款入口(rest風格)
*/
@Pointcut("execution(*p2p.service.loan.ICreditLoanService.appLoan4Channel(..))")
public void apply4Channel() {
}
/**
* 提供給自身平臺借款的介面,方法appLoan是借款入口(rest風格)
*/
@Pointcut("execution(*p2p.service.loan.ICreditLoanService.appLoan(..))")
public void appLoan() {
}
@Around("apply4Channel()")
public ObjectdoBorrow4Channel(ProceedingJoinPointpjp)throwsThrowable {
return doBorrow(pjp,"doBorrow4Channel",MongoDBUtils.CUST_ID,false);
}
@Around("appLoan()")
public ObjectdoBorrow4AppOrH5(ProceedingJoinPointpjp)throwsThrowable {
return doBorrow(pjp,"doBorrow4AppOrH5",MongoDBUtils.CUST_ID,false);
}
public ObjectdoBorrow(ProceedingJoinPointpjp, Stringintface,StringcustIdKey,booleanisReturnString)
throws Throwable {
JsonResultjsonResult= newJsonResult();
jsonResult.setSuccess(false);
booleanisLocked=false;//是否獲得鎖
StringlockName=null;
StringcustId=null;
Objectresult= null;
Object[]paramArray= null;
try {
paramArray = pjp.getArgs();// 引數陣列
custId=getCustId(paramArray[0],custIdKey);//獲取使用者編號
lockName=RedisPrekeyEnum.LOCK_CUST_BORROW_KEY.getPre_key()+custId;//使用者編號作為鎖,防止同一使用者並行借款操作
isLocked=lockHelper.lock(lockName,intface+":"+format.format(new Date()),EXPIRE_SECOND);
if(!isLocked){//未獲取到鎖
jsonResult.setMsg("重複提交的請求,請"+LOCK_MINUTES+"分鐘後再試!");
jsonResult.setMsgCode("G0002");
return toJsonStr(jsonResult,isReturnString);
}
result = pjp.proceed();//執行借款相關邏輯
return toJsonStr(result,false);
}catch(Throwablee) {
jsonResult.setMsg("系統內部錯誤[ERR001]。");
return toJsonStr(jsonResult,isReturnString);
}finally{
if(lockName!=null &&isLocked){
lockHelper.unlock(lockName);//釋放鎖
}
}
}
/**
* 將物件轉換為json字串
*/
private Object toJsonStr(ObjectjsonResult,booleanisReturnString){
returnisReturnString ?writeResult((JsonResult)jsonResult) :jsonResult;
}
/**
* 解析使用者ID
*/
private String getCustId(ObjectparamObj,StringuserKey){
Stringparam=(String)paramObj;
JSONObjectparams= JSONObject.parseObject(param);
LongcustId= params.getLong(userKey);
Stringcid=String.valueOf(custId);
if(cid==null){
throw new RuntimeException("not found param: CUST_ID");
}
cid =cid.trim();
if(cid.length()==0){
throw new RuntimeException("not found param: CUST_ID");
}
returncid;
}
}
/**
* redis鎖幫助類(操作基於springframework.data.redis)
*/
@Component("redisLockHelper")
public class RedisLockHelper {
final Logger logger = LoggerFactory.getLogger(this.getClass());
@Autowired
private StringRedisTemplate jedis;
/**
* 獲取一個redis鎖(根據key)
* (jedis.opsForValue()沒有setNX介面則通過execute實現)
* @param lockName 鎖名稱
* @param value 鎖其它資訊
* @param expire 鎖過期時間 (單位:秒)
* @return 是否成功獲取鎖:true-成功獲取鎖,false-獲取鎖失敗
*/
public boolean lock(final String lockName,final String value, final long expire) {
try {
final byte[] lockBytes = jedis.getStringSerializer().serialize(lockName);
final byte[] valueBytes = jedis.getStringSerializer().serialize(value);
Long rs= jedis.execute(new RedisCallback<Long>() {
public Long doInRedis(RedisConnection connection) {
boolean locked = connection.setNX(lockBytes, valueBytes);
if (locked) {
connection.expire(lockBytes, expire);//設定一個過期時間
return 1L;
}
//未獲取到鎖則ttl檢查過期時間(防止setNX、expire間的崩潰)
Long checkExpire=connection.ttl(lockBytes);
if(checkExpire == -1){//ttl:-1, 如果key沒有到期超時。 -2, 如果鍵不存在。
connection.expire(lockBytes, expire);//如果以前的key沒有過期機制,則設定一個過期時間
logger.warn("ttl檢查有效,重新設定失效時間! [lockName={}, expire={}]", lockName, expire);
}
return 0L;//不做排隊處理,直接返回失敗
}
});
return (rs==1L);
} catch (Exception e) {
logger.error("獲取redis鎖異常! [lockName="+lockName+"]",e);
}
return false;
}
public void unlock(final String lockName) {
try {
jedis.delete(lockName);
} catch (Exception e) {
logger.error("釋放redis鎖異常! [lockName="+lockName+"]",e);
}
}
}
總結:
1.為了降低程式碼耦合性,使用了spring AOP,防止修改原有程式碼。
2.使用redis實現分散式鎖,達到分散式部署場景下使用者借款序列處理(防止產生多筆重複借款)。
不足:
當redis伺服器宕機之後,將會導致所有借款都被阻塞,因此後期可以考慮部署redis叢集。