
大家好,我是Tom哥。
前幾天跟一位小伙伴聊天,心情特別沮喪,剛被老板罵完.....
差點丟了飯碗,還好老板沒說 “滾”。
就今年這就業(yè)行情,滿眼都是淚哇。
小伙伴在一家初創(chuàng)公司,團隊規(guī)模很小,老板為了節(jié)省成本,也沒配置什么豪華陣容。
他的工作時間也不長,負責交易訂單,前幾天接到用戶投訴,「我的訂單列表」有多條一模一樣的訂單。
雖沒造成什么資損,但嚴重影響用戶體驗。
看到這里,有經(jīng)驗的同學可能猜到,應該是接口沒做防重控制。
日常開發(fā)中,重復提交也是蠻常見問題。
比如:用戶提交一個表單,鼠標點的太快,正好前端又是個新兵蛋子,沒做任何控制,瞬間就會有多個請求發(fā)到后端系統(tǒng)。
如果后端同學也沒做兜底方案的話,悲劇就發(fā)生了。
常見的解決方案是借助數(shù)據(jù)庫自身的「唯一索引約束」,來保證數(shù)據(jù)的準確性,這種方案一般在插入場景用的多些。
變種方案可以考慮單獨創(chuàng)建一個防重表。
本文的案例有點特殊,訂單號是后端系統(tǒng)生成的,前后兩次請求無法區(qū)分重復狀態(tài),所以系統(tǒng)會創(chuàng)建兩條不同訂單 ID 記錄,繞過了「唯一索引約束」這個限制,這.....
另外,MySQL 性能也單薄了點,單機 QPS 在「千」維度,如果是面對一個高并發(fā)接口,性能也有點吃緊。
接下來,我們就來講下,借助 Redis 來實現(xiàn)接口防重復提交。
技術方案
首先,我們來看下整理的流程,如下圖所示:

大致步驟:
1、客戶端發(fā)送請求到服務端。
2、服務端接收請求,然后從請求參數(shù)中提取唯一標識。這個標識可以沒有什么特殊業(yè)務含義,client 端隨機生成即可。
3、服務端系統(tǒng)將唯一標識先嘗試寫入 Redis 緩存中,可以認為是加鎖操作。
4、加鎖失敗,說明請求還在處理,此次是重復請求,可以丟棄。
5、加鎖成功,繼續(xù)后面正常業(yè)務邏輯處理。
6、業(yè)務邏輯處理完成后,刪除加鎖的標記。
7、最后,將處理成功的結果返回給客戶端。
注意事項:
- 重復提交場景一般都是在極短時間內(nèi),同時發(fā)送了多次請求(比如:頁面表單重復提交),我們只認第一次請求為有效請求。
- 鎖用完后,要記得手動刪除。為了防止鎖沒有正常釋放,我們可以為鎖設置一個極短的過期時間(比如 10 秒)。
項目實戰(zhàn)
1、引入 redis 組件
實戰(zhàn)的項目采用 Spring Boot 搭建,這里需要引入 Redis 相關依賴。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
2、redis 變量配置
application.properties 配置文件中,添加redis相關服務配置。
spring.redis.host=127.0.0.1
spring.redis.port=6379
3、定義注解類
定義一個注解,配置在需要防重復的接口方法上,提高開發(fā)效率,同時降低代碼的耦合度。
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface IdempotentRule {
/**
* 業(yè)務自定義前綴
*/
String prefix() default "";
/**
* 業(yè)務重復標識
*/
String key() default "";
}
4、接口攔截器
上面定義了IdempotentRule?注解,需要通過攔截器對正常的業(yè)務方法做攔截,增加一些特殊邏輯處理。
@Aspect
@Component
@Slf4j
public class IdempotentAspect {
@Autowired
private RedisTemplate<String, Serializable> idempotentRedisTemplate;
@Around("execution(public * *(..)) && @annotation(com.onyone.idempotent.annotation.IdempotentRule)")
public Object limit(ProceedingJoinPoint pjp) {
MethodSignature signature = (MethodSignature) pjp.getSignature();
Object[] params = pjp.getArgs();
String[] paramNames = signature.getParameterNames();
Method method = signature.getMethod();
IdempotentRule idempotentRule = method.getAnnotation(IdempotentRule.class);
String key = idempotentRule.key();
String prefix = idempotentRule.prefix();
ExpressionParser parser = new SpelExpressionParser();
EvaluationContext context = new StandardEvaluationContext();
context.setVariable(paramNames[0], params[0]);
String repeatKey = (String) parser.parseExpression(key).getValue(context);
try {
// 先在緩存中做個標記
Boolean lockResult = idempotentRedisTemplate.opsForValue().setIfAbsent(prefix + repeatKey, "正在處理....", 20, TimeUnit.SECONDS);
if (lockResult) {
// 業(yè)務邏輯處理
return pjp.proceed();
} else {
throw new Exception("重復提交..................");
}
} catch (Throwable e) {
e.printStackTrace();
} finally {
// 處理完成后,將標記刪除
idempotentRedisTemplate.delete(prefix + repeatKey);
}
return null;
}
}
這里,比較特殊的是提取請求的唯一標識,由于不同的業(yè)務請求唯一標識不一樣。
所以,這里采用 SPEL 表達式,將規(guī)則設置能力開放出去,由業(yè)務方自己定義,比如:
@IdempotentRule(key = "#userParam.cardNumber", prefix = "repeat_")。
攔截器根據(jù) SPEL 表達式( 如 "#userParam.cardNumber")以及請求參數(shù)對象,計算當前請求唯一標識的值,
然后將值寫入 Redis 中,并設置過時間。
如果設置成功,說明是第一次請求,繼續(xù)下面的業(yè)務邏輯處理;否則,判定為重復請求,直接丟棄。
5、上層業(yè)務接口
@RestController
@RequestMapping("/user")
public class UserController {
/**
* 創(chuàng)建一個新的用戶
*/
@RequestMapping(value = "/create_user")
@IdempotentRule(key = "#userParam.cardNumber", prefix = "repeat_")
public String createUser(@RequestBody UserParam userParam) {
// 模擬業(yè)務處理
return "創(chuàng)建用戶成功!";
}
}
@Data
public class UserParam {
private String cardNumber;
private String name;
}
測試結果
1、構造客戶端請求,第一次處理成功。

2、 Redis 緩存中,能查到請求設置的鎖標記。

3、模擬重復,連續(xù)多次快速提交請求,請求會被攔截,并拋出異常。
