章
目
录
在生产环境中,为了保存用户最近操作的 n 条数据,最初采用 Redis 的 key-value 结构,但因并发修改导致数据丢失。为解决此问题,改用 Redis 的 SortedSet,以时间戳作为 score,数据作为 value,实现类似 LRU 的功能,时间复杂度为 O(log(n))。为避免并发问题,采用 Lua 脚本保证操作的原子性。脚本通过 ZADD 添加数据,ZCARD 查询集合大小,并在超出限制时通过 ZREMRANGEBYRANK 删除多余数据。通过这种方式,成功解决了并发场景下的数据丢失问题,同时保证了业务功能的高效实现。
在最近的生产环境中,我们遇到了一个业务场景:需要保存用户最近操作的 n 条数据。最初,我们采用了 Redis 的 key-value 结构,其中 value 是通过 JSON 序列化的数据集合。但在测试过程中,发现偶尔会出现数据丢失的问题。经过排查,问题根源在于并发修改导致的数据覆盖。
问题分析
在保存用户最近操作数据的场景中,很容易想到使用 LRU(Least Recently Used) 算法。然而,Redis 中并没有直接实现 LRU 的节点 + Map(例如 Java 中的 LinkedHashMap
)。如果使用 Java 中的数据结构,就会像之前的 key-value 方案一样,面临并发问题。
解决方案
综合业务需求和并发特性,我们决定使用 Redis 的 SortedSet(有序集合)。通过将操作数据的时间戳作为 score,需要保存的数据作为 value,可以实现类似 LRU 的功能,且时间复杂度为 O(log(n))。虽然稍逊于 LinkedHashMap
的 O(1),但由于每个 key 的数据量不会太大,性能完全能够满足要求。
并发问题的解决
在满足业务功能的基础上,还需要解决并发问题。如果使用原生命令,执行添加操作后,需要查询当前 set 的大小。如果大小超过设定的目标值,则删除多余的元素。但在并发场景下,这种方法很容易出现问题。例如,设定最大值为 5,当前 set 中已有 4 个元素,此时并发三个请求:
- 第一个请求保存数据后,在查询 set 大小之前,第二个请求也保存了数据。
- 此时,第一个请求会判断当前 set 超过目标值并进行删除。
- 在第一个请求删除之前,第二个请求也查询了 set 的大小,并进行删除。
- 同理,第三个请求也可能进行删除。
这样会导致数据被重复删除,且并发请求越多,额外删除的数据也越多。
针对这个问题,我们有两种解决方案:
方法一:定时任务清理
不在添加数据时进行删除操作,而是维护一个 key 的集合,通过定时任务定期检查数据并删除多余的部分。这种方法简单易实现,但可能会导致数据在短时间内超出目标值。
方法二:使用 Lua 脚本
Lua 脚本可以保证操作的原子性,从而有效解决并发问题。我们最终采用了这种方式。
Lua 脚本实现
以下是 Lua 脚本的详细代码:
private static final String ADD_RECORD_SCRIPT =
"local key = KEYS[1] " +
"local member = ARGV[1] " +
"local score = ARGV[2] " +
"local maxSize = tonumber(ARGV[3]) " +
"redis.call('ZADD', key, score, member) " +
"local size = redis.call('ZCARD', key) " +
"if size > maxSize then " +
" redis.call('ZREMRANGEBYRANK', key, 0, size - maxSize - 1) " +
"end " +
"return 1";
脚本加载与执行
加载 Lua 脚本
private void initScript() {
try {
saveScriptSha = cluster.scriptLoad(ADD_RECORD_SCRIPT);
log.info("加载lua脚本成功: {}", saveInterviewScriptSha);
} catch (Exception e) {
log.error("加载lua脚本失败", e);
throw new IllegalStateException("加载lua脚本失败", e);
}
}
执行 Lua 脚本
public void saveSingleRecord(String key, String value) {
long score = System.currentTimeMillis() / 1000;
int count = 0;
while (++count <= MAX_TRY_COUNT) {
try {
cluster.evalsha(saveScriptSha, Collections.singletonList(key), Arrays.asList(value, score + "", MAX_SIZE + ""), false);
return;
} catch (JedisNoScriptException jedisNoScriptException) {
log.error("脚本不存在: {} {}", key, value, jedisNoScriptException);
initScript();
} catch (Exception e) {
log.error("保存失败: {} {}", key, value, e);
throw new RuntimeException(e);
}
}
throw new RuntimeException("保存失败");
}
在执行过程中,需要注意脚本可能因为服务重启或执行 flush
命令等原因被清除。因此,当出现脚本不存在的异常时,我们会进行重试。