搞了个线上故障,被老板骂了.md 7.4 KB


title: 搞了个线上故障,被老板骂了....

搞了个线上故障,被老板骂了....

作者:Tom哥
公众号:微观技术
博客:https://offercome.cn
人生理念:知道的越多,不知道的越多,努力去学

背景

大家好,我是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、模拟重复,连续多次快速提交请求,请求会被拦截,并抛出异常

代码地址

https://github.com/aalansehaiyang/redis-limit-demo