实习问题复盘-redis与分布式锁

1. BG

实习需求经过了测试,上线后几天,QA对新需求的测试过程中突然上一版需求中出现了问题,导致数据重复+1。并且该问题复现时间不固定,重复+1次数不固定,在2~5之间。

2. 问题分析

经过查询服务器日志发现,本该用redis去重只执行一次的操作,执行了多次。而该部分在上一版需求单独测试与上线中并没有发现问题。
问题定位代码块如下(去隐私代码):

//如果监听到Kafka的消息,那么执行以下操作
threadPool.execute(() -> {
                        if (!redisCheck()) {
                            //业务值+1
                        }
                    }
            );


    private boolean redisCheck(args) {
        String key =args;
        String uniqueId = (String) redisTemplate.opsForValue().get(key);
        if ("key已存在".equals(uniqueId)) {
            return true;
        } else {
            //不存在key
            redisTemplate.opsForValue().setIfAbsent(key, "key已存在", 3600, TimeUnit.SECONDS);
        }
        return false;
    }

本质上 此处逻辑在监听Kafka的消息,当监听到Kafka消息时,对Kafka的消息解析出参数args,再对args去redis中检验是否重复,如果不重复,则+1操作。
测试环境下有N台测试机器部署应用,M台Kafka环境。测试环境的每个测试都有一个唯一的kafka group-id。那么在测试的时候,同一个group中的worker,只有一个worker能拿到这个数据,但每个group都可以拿到同样的所有数据。因此上诉代码监听Kafka消息部分就会收到多条消息,那么就会造成一个并发的操作。

  • 明明用redis去重了?
    注意到并发操作,那么实际上在redisTemplate的get与set方法之间存在延时(非原子操作),这里必然就出现了线程不安全的情况。所以会造成+1操作执行多次。

  • 为什么QA之前测试的时候没出错情况,线上也没有出错?
    QA在测试的时候,都是单独对当前需求分支部署后的应用进行测试,这个单独的需求分支一般只会部署到一个测试环境中,那么这个时候在这个需求里面只会存在一个Kafka group。那么部署的应用只会收到一个group下的一个worker发出的Kafka消息,固然不会出现多线程对于一个KEY并发的情况。对于线上,线上是只有一个group,固然线上不会出现这个问题。

  • 为什么上个需求上线后在新需求出现了这个问题?
    QA测完需求后交付会把代码合并到master分支,那么在新需求的测试过程中,所有的test环境下都是包含master代码的,也就是后面的所有的测试环境的实例都包含上述的代码。那么在新需求的测试过程中,test-n环境下的实例会收到对应的group下的Kafka的消息,如果有多个实例同时在线的话,就会遇到上述的并发问题。

3. 问题解决

实际上这个问题是可以不用去解决的,是测试环境多个不同的实例监听不同group的Kafka导致的。但是原因与解决还是要弄清楚。虽然这是一个并发问题,但使用普通的加锁,如java中的是无法解决问题的,因为不可能只会有一个实例运行在服务器上,所以需要分布式锁。redis的setnx命令就可避免上面的问题。

4. 基于redis实现分布式锁

分布式锁.png

分布式锁特点:

  • 互斥性:同一时刻只有一个线程持有锁
  • 可重入性:同一节点上的同一个线程如果获取锁之后能再次获取锁
  • 锁超时:防止死锁
  • 高性能与高可用:加解锁都要高效,防止分布式锁失效
  • 具有阻塞与非阻塞性,能及时从阻塞状态被唤醒

一般分布式锁实现方式:

  • 基于数据库
  • 基于zookeeper
  • 基于redis

主要就是使用redis的SETNX命令(set if not exist) 成功返回1,否则为0。

SETNX key value 将 key 的值设为 value ,当且仅当 key 不存在。 若给定的 key 已经存在,则 SETNX 不做任何动作。 SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。

返回值: 设置成功,返回 1 。 设置失败,返回 0 。
那么基于分布式锁的特性,使用setnx+expire可以实现(非原子),但如果出现错误,当setnx执行完后出现问题导致expire操作未执行,那么就会出现锁无法过期。一种改善方案就是使用Lua脚本来保证原子性(包含setnx和expire两条指令)(脚本略)

后续redis中有实现:

SET key value[EX seconds][PX milliseconds][NX|XX]
  • EX seconds: 设定过期时间,单位为秒
  • PX milliseconds: 设定过期时间,单位为毫秒
  • NX: 仅当key不存在时设置值
  • XX: 仅当key存在时设置值
    这一条命令即等同于setnx。

Solve: jedis进行操作:

String result = template.execute(new RedisCallback() {
            @Override
            public String doInRedis(RedisConnection connection) throws DataAccessException {
                JedisCommands commands = (JedisCommands) connection.getNativeConnection();
                return commands.set(key, "锁定的资源", "NX", "PX", 3000);
            }
        });

你可能感兴趣的:(实习问题复盘-redis与分布式锁)