背景描述:
公司是做互連網借貸業務的,前段時間對接了一個第三方平台,為該平台的使用者提供現金借貸業務。但是,剛上線便發現存在一個很嚴重的問題,就是在短時間內(毫秒級)同一個使用者產生了多筆借款,我們的業務情境是要求同一個使用者針對同一類借款,只可以存在一筆待還借款。經過排查發現第三方平台每次都會在同一時間發送多次相同的借款請求(其實就是第三方平台沒有控制好邏輯,極短時間內對使用者的同一個借款請求多次重試引起的)。查出問題後便立即通知第三方平台排查問題,防止使用者的重複提交,並控制好伺服器重試機制的頻率,但是顯然我們平台的安全性不能夠依賴第三方去實現。
針對該問題,想到了三個解決辦法:
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.toSeconds(LOCK_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叢集。