- 冪等接口就是多次調(diào)用不會影響到系統(tǒng)。
數(shù)據(jù)庫唯一主鍵
- 數(shù)據(jù)庫唯一主鍵的實現(xiàn)主要是利用數(shù)據(jù)庫中主鍵唯一約束的特性谷婆,一般來說唯一主鍵比較適用于“插入”時的冪等性慨蛙,其能保證一張表中只能存在一條帶該唯一主鍵的記錄。
- 使用數(shù)據(jù)庫唯一主鍵完成冪等性時需要注意的是纪挎,該主鍵一般來說并不是使用數(shù)據(jù)庫中自增主鍵期贫,而是使用分布式 ID 充當(dāng)主鍵,這樣才能能保證在分布式環(huán)境下 ID 的全局唯一性异袄。
適用操作
- 插入操作
- 刪除操作
使用限制
-
需要生成全局唯一主鍵 ID
image.png
主要流程如下:
- 客戶端執(zhí)行創(chuàng)建請求通砍,調(diào)用服務(wù)端接口。
- 服務(wù)端執(zhí)行業(yè)務(wù)邏輯烤蜕,生成一個分布式 ID封孙,將該 ID 充當(dāng)待插入數(shù)據(jù)的主鍵,然 后執(zhí)數(shù)據(jù)插入操作讽营,運(yùn)行對應(yīng)的 SQL 語句虎忌。
- 服務(wù)端將該條數(shù)據(jù)插入數(shù)據(jù)庫中,如果插入成功則表示沒有重復(fù)調(diào)用接口橱鹏。如果拋出主鍵重復(fù)異常膜蠢,則表示數(shù)據(jù)庫中已經(jīng)存在該條記錄堪藐,返回錯誤信息到客戶端。
數(shù)據(jù)庫樂觀鎖
- 數(shù)據(jù)庫樂觀鎖方案一般只能適用于執(zhí)行更新操作的過程挑围,我們可以提前在對應(yīng)的數(shù)據(jù)表中多添加一個字段庶橱,充當(dāng)當(dāng)前數(shù)據(jù)的版本標(biāo)識。
- 這樣每次對該數(shù)據(jù)庫該表的這條數(shù)據(jù)執(zhí)行更新時贪惹,都會將該版本標(biāo)識作為一個條件,值為上次待更新數(shù)據(jù)中的版本標(biāo)識的值寂嘉。
適用操作
- 更新操作
使用限制
- 需要數(shù)據(jù)庫對應(yīng)業(yè)務(wù)表中添加額外字段
為了每次執(zhí)行更新時防止重復(fù)更新奏瞬,確定更新的一定是要更新的內(nèi)容,我們通常都會添加一個 version 字段記錄當(dāng)前的記錄版本泉孩,這樣在更新時候?qū)⒃撝祹吓鸲耍敲粗灰獔?zhí)行更新操作就能確定一定更新的是某個對應(yīng)版本下的信息。
UPDATE my_table SET price=price+50,version=version+1 WHERE id=1 AND version=5
防重 Token 令牌
- 針對客戶端連續(xù)點擊或者調(diào)用方的超時重試等情況寓搬,例如提交訂單珍昨,此種操作就可以用 Token 的機(jī)制實現(xiàn)防止重復(fù)提交。
- 簡單的說就是調(diào)用方在調(diào)用接口的時候先向后端請求一個全局 ID(Token)句喷,請求的時候攜帶這個全局 ID 一起請求(Token 最好將其放到 Headers 中)镣典,后端需要對這個 Token 作為 Key,用戶信息作為 Value 到 Redis 中進(jìn)行鍵值內(nèi)容校驗唾琼,如果 Key 存在且 Value 匹配就執(zhí)行刪除命令兄春,然后正常執(zhí)行后面的業(yè)務(wù)邏輯。如果不存在對應(yīng)的 Key 或 Value 不匹配就返回重復(fù)執(zhí)行的錯誤信息锡溯,這樣來保證冪等操作赶舆。
適用操作
- 插入操作
- 更新操作
- 刪除操作
使用限制
- 需要生成全局唯一 Token 串
- 需要使用第三方組件 Redis 進(jìn)行數(shù)據(jù)效驗
- 服務(wù)端提供獲取 Token 的接口,該 Token 可以是一個序列號祭饭,也可以是一個分布式 ID 或者 UUID 串芜茵。
- 客戶端調(diào)用接口獲取 Token,這時候服務(wù)端會生成一個 Token 串倡蝙。
- 然后將該串存入 Redis 數(shù)據(jù)庫中九串,以該 Token 作為 Redis 的鍵(注意設(shè)置過期時間)。
- 將 Token 返回到客戶端悠咱,客戶端拿到后應(yīng)存到表單隱藏域中蒸辆。
- 客戶端在執(zhí)行提交表單時,把 Token 存入到 Headers 中析既,執(zhí)行業(yè)務(wù)請求帶上該 Headers躬贡。
- 服務(wù)端接收到請求后從 Headers 中拿到 Token,然后根據(jù) Token 到 Redis 中查找該 key 是否存在眼坏。
- 服務(wù)端根據(jù) Redis 中是否存該 key 進(jìn)行判斷拂玻,如果存在就將該 key 刪除酸些,然后正常執(zhí)行業(yè)務(wù)邏輯。如果不存在就拋異常檐蚜,返回重復(fù)提交的錯誤信息魄懂。
注意,在并發(fā)情況下闯第,執(zhí)行 Redis 查找數(shù)據(jù)與刪除需要保證原子性市栗,否則很可能在并發(fā)下無法保證冪等性。其實現(xiàn)方法可以使用分布式鎖或者使用 Lua 表達(dá)式來注銷查詢與刪除操作咳短。
下游傳遞唯一序列號
所謂請求序列號填帽,其實就是每次向服務(wù)端請求時候附帶一個短時間內(nèi)唯一不重復(fù)的序列號,該序列號可以是一個有序 ID咙好,也可以是一個訂單號篡腌,一般由下游生成,在調(diào)用上游服務(wù)端接口時附加該序列號和用于認(rèn)證的 ID勾效。
-
當(dāng)上游服務(wù)器收到請求信息后拿取該 序列號 和下游 認(rèn)證ID 進(jìn)行組合嘹悼,形成用于操作 Redis 的 Key,然后
- 到 Redis 中查詢是否存在對應(yīng)的 Key 的鍵值對层宫,根據(jù)其結(jié)果:
如果存在杨伙,就說明已經(jīng)對該下游的該序列號的請求進(jìn)行了業(yè)務(wù)處理,這時可以直接響應(yīng)重復(fù)請求的錯誤信息卒密。 - 如果不存在缀台,就以該 Key 作為 Redis 的鍵,以下游關(guān)鍵信息作為存儲的值(例如下游商傳遞的一些業(yè)務(wù)邏輯信息)哮奇,將該鍵值對存儲到 Redis 中 膛腐,然后再正常執(zhí)行對應(yīng)的業(yè)務(wù)邏輯即可。
- 到 Redis 中查詢是否存在對應(yīng)的 Key 的鍵值對层宫,根據(jù)其結(jié)果:
適用操作
- 插入操作
- 更新操作
- 刪除操作
使用限制
- 要求第三方傳遞唯一序列號鼎俘;
- 需要使用第三方組件 Redis 進(jìn)行數(shù)據(jù)效驗
主要流程
- 下游服務(wù)生成分布式 ID 作為序列號哲身,然后執(zhí)行請求調(diào)用上游接口,并附帶唯一序列號與請求的認(rèn)證憑據(jù)ID贸伐。
- 上游服務(wù)進(jìn)行安全效驗勘天,檢測下游傳遞的參數(shù)中是否存在序列號和憑據(jù)ID。
- 上游服務(wù)到 Redis 中檢測是否存在對應(yīng)的序列號與認(rèn)證ID組成的 Key捉邢,如果存在就拋出重復(fù)執(zhí)行的異常信息脯丝,然后響應(yīng)下游對應(yīng)的錯誤信息。如果不存在就以該序列號和認(rèn)證ID組合作為 Key伏伐,以下游關(guān)鍵信息作為 Value宠进,進(jìn)而存儲到 Redis 中,然后正常執(zhí)行接來來的業(yè)務(wù)邏輯
上面步驟中插入數(shù)據(jù)到 Redis 一定要設(shè)置過期時間藐翎。這樣能保證在這個時間范圍內(nèi)材蹬,如果重復(fù)調(diào)用接口实幕,則能夠進(jìn)行判斷識別。如果不設(shè)置過期時間堤器,很可能導(dǎo)致數(shù)據(jù)無限量的存入 Redis昆庇,致使 Redis 不能正常工作。
怎么選
- 對于下單等存在唯一主鍵的闸溃,可以使用“唯一主鍵方案”的方式實現(xiàn)整吆。
- 對于更新訂單狀態(tài)等相關(guān)的更新場景操作,使用“樂觀鎖方案”實現(xiàn)更為簡單辉川。
- 對于上下游這種掂为,下游請求上游,上游服務(wù)可以使用“下游傳遞唯一序列號方案”更為合理员串。
- 類似于前端重復(fù)提交、重復(fù)下單昼扛、沒有唯一ID號的場景寸齐,可以通過 Token 與 Redis 配合的“防重 Token 方案”實現(xiàn)更為快捷。
SpringBoot利用AOP防止請求重復(fù)提交
思路
- 自定義注解@NoRepeatSubmit 標(biāo)記所有Controller中提交的請求抄谐。
- 通過AOP對所有標(biāo)記了@NoRepeatSubmit 的方法進(jìn)行攔截渺鹦。
- 在業(yè)務(wù)方法執(zhí)行前,獲取當(dāng)前用戶的token或者JSessionId+當(dāng)前請求地址蛹含,作為一個唯一的key毅厚,去獲取redis分布式鎖,如果此時并發(fā)獲取浦箱,只有一個線程能獲取到吸耿。
- 業(yè)務(wù)執(zhí)行后,釋放鎖酷窥。
關(guān)于Redis分布式鎖
- 使用Redis是為了在負(fù)載均衡部署咽安,如果是單機(jī)的項目可以使用一個本地線程安全的Cache替代Redis
代碼
- 自定義注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {
/**
* 設(shè)置請求鎖定時間
*
* @return
*/
int lockTime() default 10;
}
- AOP
@Aspect
@Component
public class RepeatSubmitAspect {
private static final Logger LOGGER = LoggerFactory.getLogger(RepeatSubmitAspect.class);
@Autowired
private RedisLock redisLock;
@Pointcut("@annotation(noRepeatSubmit)")
public void pointCut(NoRepeatSubmit noRepeatSubmit) {
}
@Around("pointCut(noRepeatSubmit)")
public Object around(ProceedingJoinPoint pjp, NoRepeatSubmit noRepeatSubmit) throws Throwable {
int lockSeconds = noRepeatSubmit.lockTime();
HttpServletRequest request = RequestUtils.getRequest();
Assert.notNull(request, "request can not null");
// 此處可以用token或者JSessionId
String token = request.getHeader("Authorization");
String path = request.getServletPath();
String key = getKey(token, path);
String clientId = getClientId();
boolean isSuccess = redisLock.tryLock(key, clientId, lockSeconds);
LOGGER.info("tryLock key = [{}], clientId = [{}]", key, clientId);
if (isSuccess) {
LOGGER.info("tryLock success, key = [{}], clientId = [{}]", key, clientId);
// 獲取鎖成功
Object result;
try {
// 執(zhí)行進(jìn)程
result = pjp.proceed();
} finally {
// 解鎖
redisLock.releaseLock(key, clientId);
LOGGER.info("releaseLock success, key = [{}], clientId = [{}]", key, clientId);
}
return result;
} else {
// 獲取鎖失敗,認(rèn)為是重復(fù)提交的請求
LOGGER.info("tryLock fail, key = [{}]", key);
return new ApiResult(200, "重復(fù)請求蓬推,請稍后再試", null);
}
}
private String getKey(String token, String path) {
return token + path;
}
private String getClientId() {
return UUID.randomUUID().toString();
}
}
- 測試接口
@RestController
public class SubmitController {
@PostMapping("submit")
@NoRepeatSubmit(lockTime = 30)
public Object submit(@RequestBody UserBean userBean) {
try {
// 模擬業(yè)務(wù)場景
Thread.sleep(1500);
} catch (InterruptedException e) {
e.printStackTrace();
}
return new ApiResult(200, "成功", userBean.userId);
}
public static class UserBean {
private String userId;
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId == null ? null : userId.trim();
}
}
}
- 配置文件
server.port=8000
# Redis數(shù)據(jù)庫索引(默認(rèn)為0)
spring.redis.database=0
# Redis服務(wù)器地址
spring.redis.host=localhost
# Redis服務(wù)器連接端口
spring.redis.port=6379
# Redis服務(wù)器連接密碼(默認(rèn)為空)
#spring.redis.password=yourpwd
# 連接池最大連接數(shù)(使用負(fù)值表示沒有限制)
spring.redis.jedis.pool.max-active=8
# 連接池最大阻塞等待時間
spring.redis.jedis.pool.max-wait=-1ms
# 連接池中的最大空閑連接
spring.redis.jedis.pool.max-idle=8
# 連接池中的最小空閑連接
spring.redis.jedis.pool.min-idle=0
# 連接超時時間(毫秒)
spring.redis.timeout=5000ms
- 測試類(模擬測試)
@Component
public class RunTest implements ApplicationRunner {
private static final Logger LOGGER = LoggerFactory.getLogger(RunTest.class);
@Autowired
private RestTemplate restTemplate;
@Override
public void run(ApplicationArguments args) throws Exception {
System.out.println("執(zhí)行多線程測試");
String url="http://localhost:8000/submit";
CountDownLatch countDownLatch = new CountDownLatch(1);
ExecutorService executorService = Executors.newFixedThreadPool(10);
for(int i=0; i<10; i++){
String userId = "userId" + i;
HttpEntity request = buildRequest(userId);
executorService.submit(() -> {
try {
countDownLatch.await();
System.out.println("Thread:"+Thread.currentThread().getName()+", time:"+System.currentTimeMillis());
ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);
System.out.println("Thread:"+Thread.currentThread().getName() + "," + response.getBody());
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
countDownLatch.countDown();
}
private HttpEntity buildRequest(String userId) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "yourToken");
Map<String, Object> body = new HashMap<>();
body.put("userId", userId);
return new HttpEntity<>(body, headers);
}