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实现分布式锁

分布式锁特点:
- 互斥性:同一时刻只有一个线程持有锁
- 可重入性:同一节点上的同一个线程如果获取锁之后能再次获取锁
- 锁超时:防止死锁
- 高性能与高可用:加解锁都要高效,防止分布式锁失效
- 具有阻塞与非阻塞性,能及时从阻塞状态被唤醒
一般分布式锁实现方式:
- 基于数据库
- 基于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<String>() {
@Override
public String doInRedis(RedisConnection connection) throws DataAccessException {
JedisCommands commands = (JedisCommands) connection.getNativeConnection();
return commands.set(key, "锁定的资源", "NX", "PX", 3000);
}
});