接口冪等性問題處理
理論
冪等性概念
一個冪等操作的特點是任意執行多少次與執行一次產生的影響都是一樣的。
冪等函數
或冪等方法是指可以使用相同參數重複執行,並能獲得相同結果的函數。這些函數不會影響系統狀態,也不用擔心重複執行會對系統造成改變。例如,“getUsername()和setTrue()”函數就是一個冪等函數. 更復雜的操作冪等保證是利用唯一交易號(流水號)實現。
冪等性場景
-
1、查詢操作:查詢一次和查詢多次,在數據不變的情況下,查詢結果是一樣的。select是天然的冪等操作;
-
2、刪除操作:刪除操作也是冪等的,刪除一次和多次刪除都是把數據刪除。(注意可能返回結果不一樣,刪除的數據不存在,返回0,刪除的數據多條,返回結果多個) ;
-
3、唯一索引:防止新增髒數據。比如:支付寶的資金賬戶,支付寶也有用戶賬戶,每個用戶只能有一個資金賬戶,怎麼防止給用戶創建資金賬戶多個,那麼給資金賬戶表中的用戶ID加唯一索引,所以一個用戶新增成功一個資金賬戶記錄。要點:唯一索引或唯一組合索引來防止新增數據存在髒數據(當表存在唯一索引,併發時新增報錯時,再查詢一次就可以了,數據應該已經存在了,返回結果即可);
-
4、token機制:防止頁面重複提交。
原理上通過session token來實現的(也可以通過redis來實現)。當客戶端請求頁面時,服務器會生成一個隨機數Token,並且將Token放置到session當中,然後將Token發給客戶端(一般通過構造hidden表單)。 下次客戶端提交請求時,Token會隨着表單一起提交到服務器端。
服務器端第一次驗證相同過後,會將session中的Token值更新下,若用戶重複提交,第二次的驗證判斷將失敗,因爲用戶提交的表單中的Token沒變,但服務器端session中Token已經改變了。 -
5、悲觀鎖 獲取數據的時候加鎖獲取。select * from table_xxx where id=‘xxx’ for update; 注意:id字段一定是主鍵或者唯一索引,不然是鎖表,會死人的;悲觀鎖使用時一般伴隨事務一起使用,數據鎖定時間可能會很長,根據實際情況選用;
-
6、樂觀鎖——樂觀鎖只是在更新數據那一刻鎖表,其他時間不鎖表,所以相對於悲觀鎖,效率更高。樂觀鎖的實現方式多種多樣可以通過version或者其他狀態條件:
a、通過版本號實現update tablexxx set name=#name#,version=version+1 where version=#version#如下圖(來自網上);
b、通過條件限制 update tablexxx set avaiamount=avaiamount-#subAmount# where avai_amount-#subAmount# >= 0要求:quality-#subQuality# >= ,這個情景適合不用版本號,只更新是做數據安全校驗,適合庫存模型,扣份額和回滾份額,性能更高; -
7、分佈式鎖
如果是分佈是系統,構建全局唯一索引比較困難,例如唯一性的字段沒法確定,這時候可以引入分佈式鎖,通過第三方的系統(redis或zookeeper),在業務系統插入數據或者更新數據,獲取分佈式鎖,然後做操作,之後釋放鎖,這樣其實是把多線程併發的鎖的思路,引入多多個系統,也就是分佈式系統中得解決思路。要點:某個長流程處理過程要求不能併發執行,可以在流程執行之前根據某個標誌(用戶ID+後綴等)獲取分佈式鎖,其他流程執行時獲取鎖就會失敗,也就是同一時間該流程只能有一個能執行成功,執行完成後,釋放分佈式鎖(分佈式鎖要第三方系統提供); -
8、select + insert 併發不高的後臺系統,或者一些任務JOB,爲了支持冪等,支持重複執行,簡單的處理方法是,先查詢下一些關鍵數據,判斷是否已經執行過,在進行業務處理,就可以了。注意:核心高併發流程不要用這種方法;
-
9、狀態機冪等 在設計單據相關的業務,或者是任務相關的業務,肯定會涉及到狀態機(狀態變更圖),就是業務單據上面有個狀態,狀態在不同的情況下會發生變更,一般情況下存在有限狀態機,這時候,如果狀態機已經處於下一個狀態,這時候來了一個上一個狀態的變更,理論上是不能夠變更的,這樣的話,保證了有限狀態機的冪等。注意:訂單等單據類業務,存在很長的狀態流轉,一定要深刻理解狀態機,對業務系統設計能力提高有很大幫助
-
10、對外提供接口的api如何保證冪等
如銀聯提供的付款接口:需要接入商戶提交付款請求時附帶:source來源,seq序列號;source+seq在數據庫裏面做唯一索引,防止多次付款(併發時,只能處理一個請求) 。 重點:對外提供接口爲了支持冪等調用,接口有兩個字段必須傳,一個是來源source,一個是來源方序列號seq,這個兩個字段在提供方系統裏面做聯合唯一索引,這樣當第三方調用時,先在本方系統裏面查詢一下,是否已經處理過,返回相應處理結果;沒有處理過,進行相應處理,返回結果。注意,爲了冪等友好,一定要先查詢一下,是否處理過該筆業務,不查詢直接插入業務系統,會報錯,但實際已經處理了
實踐
talk is cheap, show you the code
1、redis加鎖代碼實現
// Copyright 2016-2101 Pica.
package com.pica.cloud.commercialization.crrs.util;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* @ClassName RedisLock
* @Description
* @Author Chongwen.jiang
* @Date 2019/9/10 9:44
* @ModifyDate 2019/9/10 9:44
* @Version 1.0
*/
@Slf4j
@Component
public class RedisLock {
@Autowired
private RedisTemplate redisTemplate;
/**
* @Description 添加元素
* @Author Chongwen.jiang
* @Date 2019/9/10 9:47
* @ModifyDate 2019/9/10 9:47
* @Params [key, value]
* @Return void
*/
public void set(Object key, Object value) {
if (key == null || value == null) {
return;
}
redisTemplate.opsForValue().set(key, value.toString());
}
/**
* @Description 獲取數據
* @Author Chongwen.jiang
* @Date 2019/9/10 9:49
* @ModifyDate 2019/9/10 9:49
* @Params [key]
* @Return java.lang.Object
*/
public Object get(Object key) {
if (key == null) {
return null;
}
return redisTemplate.opsForValue().get(key);
}
/**
* @Description 刪除
* @Author Chongwen.jiang
* @Date 2019/9/10 9:49
* @ModifyDate 2019/9/10 9:49
* @Params [key]
* @Return java.lang.Boolean
*/
private Boolean remove(Object key) {
if (key == null) {
return false;
}
return redisTemplate.delete(key);
}
/**
* @Description 如果已經存在返回false,否則返回true
* @Author Chongwen.jiang
* @Date 2019/9/10 9:48
* @ModifyDate 2019/9/10 9:48
* @Params [key, value, expireTime, mimeUnit]
* @Return java.lang.Boolean
*/
private Boolean setNx(Object key, Object value, Long expireTime, TimeUnit mimeUnit) {
if (key == null || value == null) {
return false;
}
return redisTemplate.opsForValue().setIfAbsent(key, value, expireTime, mimeUnit);
}
/**
* @Description 加鎖
* @Author Chongwen.jiang
* @Date 2019/9/10 9:50
* @ModifyDate 2019/9/10 9:50
* @Params [key(緩存key), waitTime(等待時間,毫秒), expireTime(key過期時間,毫秒)]
* @Return java.lang.Boolean
*/
public Boolean lock(String key, Long waitTime, Long expireTime) {
String value = UUID.randomUUID().toString().replaceAll("-", "").toLowerCase();
Boolean flag = setNx(key, value, expireTime, TimeUnit.MILLISECONDS);
// 嘗試獲取鎖 成功返回
if (flag) {
return flag;
} else {
// 獲取失敗
// 現在時間
long newTime = System.currentTimeMillis();
// 等待過期時間
long loseTime = newTime + waitTime;
// 不斷嘗試獲取鎖成功返回
while (System.currentTimeMillis() < loseTime) {
Boolean testFlag = setNx(key, value, expireTime, TimeUnit.MILLISECONDS);
if (testFlag) {
return testFlag;
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
return false;
}
/**
* @Description 釋放鎖
* @Author Chongwen.jiang
* @Date 2019/9/10 9:54
* @ModifyDate 2019/9/10 9:54
* @Params [key]
* @Return java.lang.Boolean
*/
public Boolean unLock(Object key) {
return remove(key);
}
}
測試一下:
@Autowired
private RedisLock redisLock;
@Test
public void testRedisLock(){
String key = "aaaa";
try {
Boolean locked = redisLock.lock(key, 1000L, 1000*60L);
if(locked) {
System.out.println("獲取鎖成功,開始執行業務代碼...");
Thread.sleep(1000*10);
} else {
System.out.println("獲取鎖失敗...");
}
} catch (Exception e) {
log.error(e.getMessage());
e.printStackTrace();
} finally {
log.info("釋放鎖:{}", redisLock.unLock(key));
}
}
2、Redisson高性能分佈式鎖代碼實現(AOP實現)
在一些高併發的場景中,比如秒殺,搶票,搶購這些場景,都存在對核心資源,商品庫存的爭奪,控制不好,庫存數量可能被減少到負數,出現超賣的情況,或者 產生唯一的一個遞增ID,由於web應用部署在多個機器上,簡單的同步加鎖是無法實現的,給數據庫加鎖的話,對於高併發,1000/s的併發,數據庫可能由行鎖變成表鎖,性能下降會厲害。那相對而言,redis的分佈式鎖,相對而言,是個很好的選擇,redis官方推薦使用的Redisson就提供了分佈式鎖和相關服務。
添加maven依賴
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.5.5</version>
</dependency>
RedissionConfig配置類:
這裏使用的是單機連接模式,redisson提供了單機、主從、哨兵、集羣等多種連接方式,感興趣的可以自行從官網瞭解學習
// Copyright 2016-2101 Pica.
package com.pica.cloud.commercialization.crrs.config;
import io.netty.channel.nio.NioEventLoopGroup;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.client.codec.Codec;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.ClassUtils;
/**
* @ClassName RedissionConfig
* @Description
* @Author Chongwen.jiang
* @Date 2019/9/6 10:40
* @ModifyDate 2019/9/6 10:40
* @Version 1.0
*/
@Slf4j
@Data
@Configuration
public class RedissonConfig {
private String address = "redis://192.168.110.241:6380";
private int connectionMinimumIdleSize = 10;
private int idleConnectionTimeout = 10000;
private int pingTimeout = 1000;
private int connectTimeout = 10000;
private int timeout = 3000;
private int retryAttempts = 3;
private int retryInterval = 1500;
private int reconnectionTimeout = 3000;
private int failedAttempts = 3;
private String password = null;
private int subscriptionsPerConnection = 5;
private String clientName = null;
private int subscriptionConnectionMinimumIdleSize = 1;
private int subscriptionConnectionPoolSize = 50;
private int connectionPoolSize = 64;
private int database = 0;
private boolean dnsMonitoring = false;
private int dnsMonitoringInterval = 5000;
/**
* 當前處理核數量 * 2
*/
private int thread = 2;
/** redisson默認使用的序列化編碼類 */
//private String codec = "org.redisson.codec.JsonJacksonCodec";
private String codec = "org.redisson.codec.FstCodec";
/**
* @Description redisson單機連接模式
* @Author Chongwen.jiang
* @Date 2019/9/10 12:53
* @ModifyDate 2019/9/10 12:53
* @Params []
* @Return org.redisson.api.RedissonClient
*/
@Bean(destroyMethod = "shutdown")
RedissonClient redisson() throws Exception {
log.info("redission init......");
Config config = new Config();
config.useSingleServer().setAddress(address)
.setConnectionMinimumIdleSize(connectionMinimumIdleSize)
.setConnectionPoolSize(connectionPoolSize)
.setDatabase(database)
.setDnsMonitoring(dnsMonitoring)
.setDnsMonitoringInterval(dnsMonitoringInterval)
.setSubscriptionConnectionMinimumIdleSize(subscriptionConnectionMinimumIdleSize)
.setSubscriptionConnectionPoolSize(subscriptionConnectionPoolSize)
.setSubscriptionsPerConnection(subscriptionsPerConnection)
.setClientName(clientName)
.setFailedAttempts(failedAttempts)
.setRetryAttempts(retryAttempts)
.setRetryInterval(retryInterval)
.setReconnectionTimeout(reconnectionTimeout)
.setTimeout(timeout)
.setConnectTimeout(connectTimeout)
.setIdleConnectionTimeout(idleConnectionTimeout)
.setPingTimeout(pingTimeout)
.setPassword(password);
Codec codec = (Codec) ClassUtils.forName(getCodec(),
ClassUtils.getDefaultClassLoader()).newInstance();
config.setCodec(codec);
config.setThreads(thread);
config.setEventLoopGroup(new NioEventLoopGroup());
config.setUseLinuxNativeEpoll(false);
return Redisson.create(config);
}
}
這裏值得注意的是redisson使用的序列化編碼類是org.redisson.codec.JsonJacksonCodec,JsonJackson在序列化有雙向引用的對象時,會出現無限循環異常。而fastjson在檢查出雙向引用後會自動用引用符$ref替換,終止循環。
如果序列化了一個service,這個service已被spring託管,而且和另一個service之間也相互注入了,用fastjson能 正常序列化到redis,而JsonJackson則拋出無限循環異常。
爲了序列化後的內容可見,所以使用FstCodec(org.redisson.codec.FstCodec)
定義註解類
package com.pica.cloud.commercialization.crrs.interceptor;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
/**
* @ClassName RLock
* @Description 分佈式鎖
* @Author Chongwen.jiang
* @Date 2019/9/6 10:47
* @ModifyDate 2019/9/6 10:47
* @Version 1.0
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface Rlock {
/** 分佈式鎖的key */
String localKey();
/** 鎖釋放時間 默認5秒 */
long leaseTime() default 5 * 1000;
/** 時間格式 默認:毫秒 */
TimeUnit timeUtil() default TimeUnit.MILLISECONDS;
}
註解業務實現類:
// Copyright 2016-2101 Pica.
package com.pica.cloud.commercialization.crrs.interceptor;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.binary.Hex;
import org.apache.commons.lang.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.HttpServletRequest;
import java.security.Key;
import java.util.Enumeration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* @ClassName RlockAspect
* @Description 分佈式鎖aop配置
* @Author Chongwen.jiang
* @Date 2019/9/6 10:52
* @ModifyDate 2019/9/6 10:52
* @Version 1.0
*/
@Slf4j
@Aspect
@Component
public class RlockAspect {
@Autowired
private RedissonClient redissonClient;
ThreadLocal<HttpServletRequest> request = new ThreadLocal();
@Pointcut("@annotation(com.pica.cloud.commercialization.crrs.interceptor.Rlock)")
public void RlockAspect() {
log.info("RlockAspect constructor...");
}
@Around("RlockAspect()")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
String params = getAllRequestParam(proceedingJoinPoint);
Object object = null;
RLock lock = null;
try {
Rlock rlockInfo = getRlockInfo(proceedingJoinPoint);
lock = redissonClient.getLock(getLocalKey(proceedingJoinPoint, rlockInfo, params));
if (null != lock) {
final boolean status = lock.tryLock(rlockInfo.leaseTime(), rlockInfo.timeUtil());
if (status) {
log.info("獲取到鎖.....");
object = proceedingJoinPoint.proceed();
}
}
} finally {
if (lock != null) {
log.info("釋放鎖.....");
lock.unlock();
}
}
return object;
}
public Rlock getRlockInfo(ProceedingJoinPoint proceedingJoinPoint) {
MethodSignature methodSignature = (MethodSignature) proceedingJoinPoint.getSignature();
return methodSignature.getMethod().getAnnotation(Rlock.class);
}
/**
* @Description 生成redis lock key
* @Author Chongwen.jiang
* @Date 2019/9/6 11:05
* @ModifyDate 2019/9/6 11:05
* @Params [proceedingJoinPoint, rlockInfo]
* @Return java.lang.String
*/
public String getLocalKey(ProceedingJoinPoint proceedingJoinPoint, Rlock rlockInfo, String params) {
StringBuffer localKey = new StringBuffer("Rlock");
//body中的參數
final Object[] args = proceedingJoinPoint.getArgs();
MethodSignature methodSignature = (MethodSignature) proceedingJoinPoint.getSignature();
//方法名稱
String methodName = methodSignature.getMethod().getName();
//參數加密
//params = jdkAES(params);
localKey.append(rlockInfo.localKey()).append("-")
.append(methodName).append("-")
.append(params);
log.info("getLocalKey: {}", localKey.toString());
return localKey.toString();
}
/**
* @Description 獲取query、body和header中的所有參數
* @Author Chongwen.jiang
* @Date 2019/9/6 13:11
* @ModifyDate 2019/9/6 13:11
* @Params [request]
* @Return java.util.Map<java.lang.String,java.lang.String>
*/
private String getAllRequestParam(ProceedingJoinPoint proceedingJoinPoint) {
RequestAttributes ra = RequestContextHolder.getRequestAttributes();
ServletRequestAttributes sra = (ServletRequestAttributes) ra;
HttpServletRequest request = sra.getRequest();
this.request.set(request);
//參數獲取
Map<String, Object> params = new ConcurrentHashMap<>();
params.put("url", request.getRequestURL());
params.put("method", request.getMethod());
Object[] bodyParams = proceedingJoinPoint.getArgs();
if (null != bodyParams && bodyParams.length > 0) {
params.put("bodyParams", bodyParams);
}
String queryParams = request.getQueryString();
if (StringUtils.isNotEmpty(queryParams)) {
params.put("queryParams", queryParams);
}
Map<String, String> headerParams = new ConcurrentHashMap<>();
Enumeration<?> headers = request.getHeaderNames();
if (null != headers) {
while (headers.hasMoreElements()) {
String element = (String) headers.nextElement();
String value = request.getHeader(element);
if ("token".equals(element)) {
headerParams.put(element, value);
}
if (StringUtils.isEmpty(element)) {
headerParams.remove(element);
}
}
}
if (!CollectionUtils.isEmpty(headerParams)) {
params.put("headerParams", headerParams);
}
String json = JSON.toJSONString(params);
log.info("getAllRequestParam: {}", json);
return json;
}
/**
* @Description 使用jdk實現AES加密
* @Author Chongwen.jiang
* @Date 2019/9/6 13:48
* @ModifyDate 2019/9/6 13:48
* @Params [source]
* @Return void
*/
private String jdkAES(String source) {
try {
// 生成KEY
KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
keyGenerator.init(128);
// 產生密鑰
SecretKey secretKey = keyGenerator.generateKey();
// 獲取密鑰
byte[] keyBytes = secretKey.getEncoded();
// KEY轉換
Key key = new SecretKeySpec(keyBytes, "AES");
// 加密
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, key);
byte[] result = cipher.doFinal(source.getBytes());
return Hex.encodeHexString(result);
/*System.out.println("jdk aes encrypt:" + Hex.encodeHexString(result));
// 解密
cipher.init(Cipher.DECRYPT_MODE, key);
result = cipher.doFinal(result);
System.out.println("jdk aes decrypt:" + new String(result));*/
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
}
controller層使用註解
@PostMapping("/{id}")
@Rlock(localKey = "redisLockAnnotion", leaseTime = 10, timeUtil = TimeUnit.SECONDS)
public ApiResponse<?> order(@RequestParam(required = false) String a, @PathVariable String id) {
Map<String, Object> resultMap = dictService.doModify();
return new ApiResponse<>(resultMap);
}
這裏我設置的鎖釋放時間是10秒,所以就需要程序員自己估計業務代碼執行時間一定不能超過該時間,否則在釋放鎖的時候,redisson會拋出異常