幂等性介紹
現如今很多系統都會基于分布式或微服務思想完成對系統的架構設計。那麼在這一個系統中,就會存在若幹個微服務,而且服務間也會産生互相通信調用。那麼既然産生了服務調用,就必然會存在服務調用延遲或失敗的問題。當出現這種問題,服務端會進行重試等操作或用戶端有可能會進行多次點選送出。如果這樣請求多次的話,那最終處理的資料結果就一定要保證統一,如支付場景。此時就需要通過保證業務幂等性方案來完成。
什麼是幂等性
幂等是一個數學與計算機學概念,即 f(n) = 1^n,無論 n 為多少,f (n) 的值永遠為 1,在數學中某一進制運算為幂等時,其作用在任一進制素兩次後會和其作用一次的結果相同。
在程式設計開發中,對于幂等的定義為:無論對某一個資源操作了多少次,其影響都應是相同的。 換句話說就是:在接口重複調用的情況下,對系統産生的影響是一樣的,但是傳回值允許不同,如查詢。
幂等函數或幂等方法是指可以使用相同參數重複執行,并能獲得相同結果的函數。這些函數不會影響系統狀态,也不用擔心重複執行會對系統造成改變。
幂等性不僅僅隻是一次或多次操作對資源沒有産生影響,還包括第一次操作産生影響後,以後多次操作不會再産生影響。并且幂等關注的是是否對資源産生影響,而不關注結果。
幂等性次元
幂等性設計主要從兩個次元進行考慮:空間、時間。
- 空間:定義了幂等的範圍,如生成訂單的話,不允許出現重複下單。
- 時間:定義幂等的有效期。有些業務需要永久性保證幂等,如下單、支付等。而部分業務隻要保證一段時間幂等即可。
同時對于幂等的使用一般都會伴随着出現鎖的概念,用于解決并發安全問題。
以 SQL 為例
- select * from table where id=1。此 SQL 無論執行多少次,雖然結果有可能出現不同,都不會對資料産生改變,具備幂等性。
- insert into table(id,name) values(1,'heima')。此 SQL 如果 id 或 name 有唯一性限制,多次操作隻允許插入一條記錄,則具備幂等性。如果不是,則不具備幂等性,多次操作會産生多條資料。
- update table set score=100 where id = 1。此 SQL 無論執行多少次,對資料産生的影響都是相同的。具備幂等性。
- update table set score=50+score where id = 1。此 SQL 涉及到了計算,每次操作對資料都會産生影響。不具備幂等性。
- delete from table where id = 1。此 SQL 多次操作,産生的結果相同,具備幂等性。
什麼是接口幂等性
在 HTTP/1.1 中,對幂等性進行了定義。
它描述了一次和多次請求某一個資源對于資源本身應該具有同樣的結果(網絡逾時等問題除外),即第一次請求的時候對資源産生了副作用,但是以後的多次請求都不會再對資源産生副作用。
這裡的副作用是不會對結果産生破壞或者産生不可預料的結果。也就是說,其任意多次執行對資源本身所産生的影響均與一次執行的影響相同。
為什麼需要實作幂等性
使用幂等性最大的優勢在于使接口保證任何幂等性操作,免去因重試等造成系統産生的未知的問題。
在接口調用時一般情況下都能正常傳回資訊不會重複送出,不過在遇見以下情況時可以就會出現問題:
前端重複送出表單
在填寫一些表格時候,使用者填寫完成送出,很多時候會因網絡波動沒有及時對使用者做出送出成功響應,緻使使用者認為沒有成功送出,然後一直點送出按鈕,這時就會發生重複送出表單請求。
使用者惡意進行刷單
例如在實作使用者投票這種功能時,如果使用者針對一個使用者進行重複送出投票,這樣會導緻接口接收到使用者重複送出的投票資訊,這樣會使投票結果與事實嚴重不符。
接口逾時重複送出
很多時候 HTTP 用戶端工具都預設開啟逾時重試的機制,尤其是第三方調用接口時候,為了防止網絡波動逾時等造成的請求失敗,都會添加重試機制,導緻一個請求送出多次。
消息進行重複消費
當使用 MQ 消息中間件時候,如果發生消息中間件出現錯誤未及時送出消費資訊,導緻發生重複消費。
引入幂等性後對系統有什麼影響
幂等性是為了簡化用戶端邏輯處理,能放置重複送出等操作,但卻增加了服務端的邏輯複雜性和成本,其主要是:
- 把并行執行的功能改為串行執行,降低了執行效率。
- 增加了額外控制幂等的業務邏輯,複雜化了業務功能;
是以在使用時候需要考慮是否引入幂等性的必要性,根據實際業務場景具體分析,除了業務上的特殊要求外,一般情況下不需要引入的接口幂等性。
Restful API 接口幂等
現在流行的 Restful 推薦的幾種 HTTP 接口方法中,分别存在幂等行與不能保證幂等的方法,如下:
HTTP 協定語義幂等性
HTTP 協定有兩種方式:RESTFUL、SOA。現在對于 WEB API,更多的會使用 RESTFUL 風格定義。為了更好的完成接口語義定義,HTTP 對于常用的四種請求方式也定義了幂等性的語義。
- GET:用于擷取資源,多次操作不會對資料産生影響,具有幂等性。注意不是結果。
- POST:用于新增資源,對同一個 URI 進行兩次 POST 操作會在服務端建立兩個資源,不具有幂等性。
- PUT:用于修改資源,對同一個 URI 進行多次 PUT 操作,産生的影響和第一次相同,具備幂等性。
- DELETE:用于删除資源,對同一個 URI 進行多次 DELETE 操作,産生的影響和第一次相同,具備幂等性
綜上所述,這些僅僅隻是 HTTP 協定建議在基于 RESTFUL 風格定義 WEB API 時的語義,并非強制性。同時對于幂等性的實作,肯定是通過前端或服務端完成。
業務問題抛出
在業務開發與分布式系統設計中,幂等性是一個非常重要的概念,有非常多的場景需要考慮幂等性的問題,尤其對于現在的分布式系統,經常性的考慮重試、重發等操作,一旦産生這些操作,則必須要考慮幂等性問題。以交易系統、支付系統等尤其明顯,如:
- 當使用者購物進行下單操作,使用者操作多次,但訂單系統對于本次操作隻能産生一個訂單。
- 當使用者對訂單進行付款,支付系統不管出現什麼問題,應該隻對使用者扣一次款。
- 當支付成功對庫存扣減時,庫存系統對訂單中商品的庫存數量也隻能扣減一次。
- 當對商品進行發貨時,也需保證物流系統有且隻能發一次貨。
在電商系統中還有非常多的場景需要保證幂等性。但是一旦考慮幂等後,服務邏輯務必會變的更加複雜。是以是否要考慮幂等,需要根據具體業務場景具體分析。而且在實作幂等時,還會把并行執行的功能改為串行化,降低了執行效率。
此處以下單減庫存為例,當使用者生成訂單成功後,會對訂單中商品進行扣減庫存。 訂單服務會調用庫存服務進行庫存扣減。庫存服務會完成具體扣減實作。
現在對于功能調用的設計,有可能出現調用逾時,因為出現如網絡抖動,雖然庫存服務執行成功了,但結果并沒有在逾時時間内傳回,則訂單服務也會進行重試。那就會出現問題,stock 對于之前的執行已經成功了,隻是結果沒有按時傳回。而訂單服務又重新發起請求對商品進行庫存扣減。 此時出現庫存扣減兩次的問題。 對于這種問題,就需要通過幂等性進行結果。
解決方案
對于幂等的考慮,主要解決兩點前後端互動與服務間互動。這兩點有時都要考慮幂等性的實作。從前端的思路解決的話,主要有三種:前端防重、PRG 模式、Token 機制。
前端防重
通過前端防重保證幂等是最簡單的實作方式,前端相關屬性和 JS 代碼即可完成設定。可靠性并不好,有經驗的人員可以通過工具跳過頁面仍能重複送出。主要适用于表單重複送出或按鈕重複點選。
PRG 模式
PRG 模式即 POST-REDIRECT-GET。當使用者進行表單送出時,會重定向到另外一個送出成功頁面,而不是停留在原先的表單頁面。這樣就避免了使用者重新整理導緻重複送出。同時防止了通過浏覽器按鈕前進 / 後退導緻表單重複送出。是一種比較常見的前端防重政策。
Token 模式
通過 token 機制來保證幂等是一種非常常見的解決方案,同時也适合絕大部分場景。該方案需要前後端進行一定程度的互動來完成。
Token 防重實作
針對用戶端連續點選或者調用方的逾時重試等情況,例如送出訂單,此種操作就可以用 Token 的機制實作防止重複送出。
簡單的說就是調用方在調用接口的時候先向後端請求一個全局 ID(Token),請求的時候攜帶這個全局 ID 一起請求(Token 最好将其放到 Headers 中),後端需要對這個 Token 作為 Key,使用者資訊作為 Value 到 Redis 中進行鍵值内容校驗,如果 Key 存在且 Value 比對就執行删除指令,然後正常執行後面的業務邏輯。如果不存在對應的 Key 或 Value 不比對就傳回重複執行的錯誤資訊,這樣來保證幂等操作。
适用操作
- 插入操作
- 更新操作
- 删除操作
使用限制
- 需要生成全局唯一 Token 串
- 需要使用第三方元件 Redis 進行資料效驗
主要流程
- 服務端提供擷取 Token 的接口,該 Token 可以是一個序列号,也可以是一個分布式 ID 或者 UUID 串。
- 用戶端調用接口擷取 Token,這時候服務端會生成一個 Token 串。
- 然後将該串存入 Redis 資料庫中,以該 Token 作為 Redis 的鍵(注意設定過期時間)。
- 将 Token 傳回到用戶端,用戶端拿到後應存到表單隐藏域中。
- 用戶端在執行送出表單時,把 Token 存入到 Headers 中,執行業務請求帶上該 Headers。
- 服務端接收到請求後從 Headers 中拿到 Token,然後根據 Token 到 Redis 中查找該 key 是否存在。
- 服務端根據 Redis 中是否存該 key 進行判斷,如果存在就将該 key 删除,然後正常執行業務邏輯。如果不存在就抛異常,傳回重複送出的錯誤資訊。
注意,在并發情況下,執行 Redis 查找資料與删除需要保證原子性,否則很可能在并發下無法保證幂等性。其實作方法可以使用分布式鎖或者使用 Lua 表達式來登出查詢與删除操作。
實作流程
通過 token 機制來保證幂等是一種非常常見的解決方案,同時也适合絕大部分場景。該方案需要前後端進行一定程度的互動來完成。
- 服務端提供擷取 token 接口,供用戶端進行使用。服務端生成 token 後,如果目前為分布式架構,将 token 存放于 redis 中,如果是單體架構,可以儲存在 jvm 緩存中。
- 當用戶端擷取到 token 後,會攜帶着 token 發起請求。
- 服務端接收到用戶端請求後,首先會判斷該 token 在 redis 中是否存在。如果存在,則完成進行業務處理,業務處理完成後,再删除 token。如果不存在,代表目前請求是重複請求,直接向用戶端傳回對應辨別。
業務執行時機
先執行業務再删除 token
但是現在有一個問題,目前是先執行業務再删除 token。
在高并發下,很有可能出現第一次通路時 token 存在,完成具體業務操作。但在還沒有删除 token 時,用戶端又攜帶 token 發起請求,此時,因為 token 還存在,第二次請求也會驗證通過,執行具體業務操作。
對于這個問題的解決方案的思想就是并行變串行。會造成一定性能損耗與吞吐量降低。
- 第一種方案:對于業務代碼執行和删除 token 整體加線程鎖。當後續線程再來通路時,則阻塞排隊。
- 第二種方案:借助 redis 單線程和 incr 是原子性的特點。當第一次擷取 token 時,以 token 作為 key,對其進行自增。然後将 token 進行傳回,當用戶端攜帶 token 通路執行業務代碼時,對于判斷 token 是否存在不用删除,而是對其繼續 incr。如果 incr 後的傳回值為 2。則是一個合法請求允許執行,如果是其他值,則代表是非法請求,直接傳回。
先删除 token 再執行業務
那如果先删除 token 再執行業務呢?其實也會存在問題,假設具體業務代碼執行逾時或失敗,沒有向用戶端傳回明确結果,那用戶端就很有可能會進行重試,但此時之前的 token 已經被删除了,則會被認為是重複請求,不再進行業務處理。
這種方案無需進行額外處理,一個 token 隻能代表一次請求。一旦業務執行出現異常,則讓用戶端重新擷取令牌,重新發起一次通路即可。推薦使用先删除 token 方案
但是無論先删 token 還是後删 token,都會有一個相同的問題。每次業務請求都回産生一個額外的請求去擷取 token。但是,業務失敗或逾時,在生産環境下,一萬個裡最多也就十個左右會失敗,那為了這十來個請求,讓其他九千九百多個請求都産生額外請求,就有一些得不償失了。雖然 redis 性能好,但是這也是一種資源的浪費。
基于業務實作
生成 Token
修改 token_service_order 工程中 OrderController,新增生成令牌方法 genToken
@Autowired
private IdWorker idWorker;
@Autowired
private RedisTemplate redisTemplate;
@GetMapping("/genToken")
public String genToken(){
String token = String.valueOf(idWorker.nextId());
redisTemplate.opsForValue().set(token,0,30, TimeUnit.MINUTES);
return token;
}
新增接口
修改 token_service_api 工程,新增 OrderFeign 接口。
@FeignClient(name = "order")
@RequestMapping("/order")
public interface OrderFeign {
@GetMapping("/genToken")
public String genToken();
}
擷取 token
修改 token_web_order 工程中 WebOrderController,新增擷取 token 方法
@RestController
@RequestMapping("worder")
public class WebOrderController {
@Autowired
private OrderFeign orderFeign;
/**
* 服務端生成token
* @return
*/
@GetMapping("/genToken")
public String genToken(){
String token = orderFeign.genToken();
return token;
}
}
攔截器
修改 token_common,新增 feign 攔截器
@Component
public class FeignInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate requestTemplate) {
//傳遞令牌
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes != null){
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
if (request != null){
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()){
String headerName = headerNames.nextElement();
if ("token".equals(headerName)){
String headerValue = request.getHeader(headerName);
//傳遞token
requestTemplate.header(headerName,headerValue);
}
}
}
}
}
}
啟動類
修改 token_web_order 啟動類
@Bean
public FeignInterceptor feignInterceptor(){
return new FeignInterceptor();
}
新增訂單
修改 token_service_order 中 OrderController,新增添加訂單方法
/**
* 生成訂單
* @param order
* @return
*/
@PostMapping("/genOrder")
public String genOrder(@RequestBody Order order, HttpServletRequest request){
//擷取令牌
String token = request.getHeader("token");
//校驗令牌
try {
if (redisTemplate.delete(token)){
//令牌删除成功,代表不是重複請求,執行具體業務
order.setId(String.valueOf(idWorker.nextId()));
order.setCreateTime(new Date());
order.setUpdateTime(new Date());
int result = orderService.addOrder(order);
if (result == 1){
System.out.println("success");
return "success";
}else {
System.out.println("fail");
return "fail";
}
}else {
//删除令牌失敗,重複請求
System.out.println("repeat request");
return "repeat request";
}
}catch (Exception e){
throw new RuntimeException("系統異常,請重試");
}
}
修改 token_service_order_api 中 OrderFeign。
@FeignClient(name = "order")
@RequestMapping("/order")
public interface OrderFeign {
@PostMapping("/genOrder")
public String genOrder(@RequestBody Order order);
@GetMapping("/genToken")
public String genToken();
}
修改 token_web_order 中 WebOrderController,新增添加訂單方法
/**
* 新增訂單
*/
@PostMapping("/addOrder")
public String addOrder(@RequestBody Order order){
String result = orderFeign.genOrder(order);
return result;
}
測試
通過 postman 擷取令牌,将令牌放入請求頭中。開啟兩個 postman tab 頁面。同時添加訂單,可以發現一個執行成功,另一個重複請求。
{"id":"123321","totalNum":1,"payMoney":1,"payType":"1","payTime":"2020-05-20","receiverContact":"heima","receiverMobile":"15666666666","receiverAddress":"beijing"}
基于自定義注解實作
直接把 token 實作嵌入到方法中會造成大量重複代碼的出現。是以可以通過自定義注解将上述代碼進行改造。在需要保證幂等的方法上,添加自定義注解即可。
自定義注解
在 token_common 中建立自定義注解 Idemptent
/**
* 幂等性注解
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idemptent {
}
建立攔截器
在 token_common 中建立攔截器
public class IdemptentInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
Idemptent annotation = method.getAnnotation(Idemptent.class);
if (annotation != null){
//進行幂等性校驗
checkToken(request);
}
return true;
}
@Autowired
private RedisTemplate redisTemplate;
//幂等性校驗
private void checkToken(HttpServletRequest request) {
String token = request.getHeader("token");
if (StringUtils.isEmpty(token)){
throw new RuntimeException("非法參數");
}
boolean delResult = redisTemplate.delete(token);
if (!delResult){
//删除失敗
throw new RuntimeException("重複請求");
}
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
配置攔截器
修改 token_service_order 啟動類,讓其繼承 WebMvcConfigurerAdapter
@Bean
public IdemptentInterceptor idemptentInterceptor() {
return new IdemptentInterceptor();
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
//幂等攔截器
registry.addInterceptor(idemptentInterceptor());
super.addInterceptors(registry);
}
添加注解
更新 token_service_order 與 token_service_order_api,新增添加訂單方法,并且方法添加自定義幂等注解
@Idemptent
@PostMapping("/genOrder2")
public String genOrder2(@RequestBody Order order){
order.setId(String.valueOf(idWorker.nextId()));
order.setCreateTime(new Date());
order.setUpdateTime(new Date());
int result = orderService.addOrder(order);
if (result == 1){
System.out.println("success");
return "success";
}else {
System.out.println("fail");
return "fail";
}
}
測試
擷取令牌後,在 jemeter 中模拟高并發通路,設定 50 個并發通路
新增一個 http request,并設定相關資訊
添加 HTTP Header Manager
文章來源:https://my.oschina.net/jiagoushi/blog/8676849