大家好,我是Tom哥。
前几天跟一位小伙伴聊天,心情特别沮丧,刚被老板骂完.....
差点丢了饭碗,还好老板没说 “滚”。
就今年这就业行情,满眼都是泪哇。
小伙伴在一家初创公司,团队规模很小,老板为了节省成本,也没配置什么豪华阵容。
他的工作时间也不长,负责交易订单,前几天接到用户投诉,「我的订单列表」有多条一模一样的订单。
虽没造成什么资损,但严重影响用户体验。
看到这里,有经验的同学可能猜到,应该是接口没做防重控制。
日常开发中,重复提交也是蛮常见问题。
比如:用户提交一个表单,鼠标点的太快,正好前端又是个新兵蛋子,没做任何控制,瞬间就会有多个请求发到后端系统。
如果后端同学也没做兜底方案的话,悲剧就发生了。
常见的解决方案是借助数据库自身的「唯一索引约束」,来保证数据的准确性,这种方案一般在插入场景用的多些。
变种方案可以考虑单独创建一个防重表。
本文的案例有点特殊,订单号是后端系统生成的,前后两次请求无法区分重复状态,所以系统会创建两条不同订单 ID 记录,绕过了「唯一索引约束」这个限制,这.....
另外,MySQL 性能也单薄了点,单机 QPS 在「千」维度,如果是面对一个高并发接口,性能也有点吃紧。
接下来,我们就来讲下,借助 Redis 来实现接口防重复提交。
技术方案
首先,我们来看下整理的流程,如下图所示:
大致步骤:
1、客户端发送请求到服务端。
2、服务端接收请求,然后从请求参数中提取唯一标识。这个标识可以没有什么特殊业务含义,client 端随机生成即可。
3、服务端系统将唯一标识先尝试写入 Redis 缓存中,可以认为是加锁操作。
4、加锁失败,说明请求还在处理,此次是重复请求,可以丢弃。
5、加锁成功,继续后面正常业务逻辑处理。
6、业务逻辑处理完成后,删除加锁的标记。
7、最后,将处理成功的结果返回给客户端。
注意事项:
- 重复提交场景一般都是在极短时间内,同时发送了多次请求(比如:页面表单重复提交),我们只认第一次请求为有效请求。
- 锁用完后,要记得手动删除。为了防止锁没有正常释放,我们可以为锁设置一个极短的过期时间(比如 10 秒)。
项目实战
1、引入 redis 组件
实战的项目采用 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、定义注解类
定义一个注解,配置在需要防重复的接口方法上,提高开发效率,同时降低代码的耦合度。
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface IdempotentRule {
/**
* 业务自定义前缀
*/
String prefix() default "";
/**
* 业务重复标识
*/
String key() default "";
}
4、接口拦截器
上面定义了IdempotentRule注解,需要通过拦截器对正常的业务方法做拦截,增加一些特殊逻辑处理。
@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) {
// 业务逻辑处理
return pjp.proceed();
} else {
throw new Exception("重复提交..................");
}
} catch (Throwable e) {
e.printStackTrace();
} finally {
// 处理完成后,将标记删除
idempotentRedisTemplate.delete(prefix + repeatKey);
}
return null;
}
}
这里,比较特殊的是提取请求的唯一标识,由于不同的业务请求唯一标识不一样。
所以,这里采用 SPEL 表达式,将规则设置能力开放出去,由业务方自己定义,比如:
@IdempotentRule(key = "#userParam.cardNumber", prefix = "repeat_")。
拦截器根据 SPEL 表达式( 如 "#userParam.cardNumber")以及请求参数对象,计算当前请求唯一标识的值,
然后将值写入 Redis 中,并设置过时间。
如果设置成功,说明是第一次请求,继续下面的业务逻辑处理;否则,判定为重复请求,直接丢弃。
5、上层业务接口
@RestController
@RequestMapping("/user")
public class UserController {
/**
* 创建一个新的用户
*/
@RequestMapping(value = "/create_user")
@IdempotentRule(key = "#userParam.cardNumber", prefix = "repeat_")
public String createUser(@RequestBody UserParam userParam) {
// 模拟业务处理
return "创建用户成功!";
}
}
@Data
public class UserParam {
private String cardNumber;
private String name;
}
测试结果
1、构造客户端请求,第一次处理成功。
2、 Redis 缓存中,能查到请求设置的锁标记。
3、模拟重复,连续多次快速提交请求,请求会被拦截,并抛出异常。