如何在Redis中实现等效唯一索引并避免竞态条件?
哇,这个需求太典型了——用Redis做持久化存储还得保证用户ID和邮箱的双唯一性,不能静默覆盖数据,还要彻底避免竞态条件。我之前帮团队解决过几乎一模一样的问题,给你两个靠谱的方案,咱们慢慢拆解:
核心问题分析
你当前用MULTI/EXEC的问题在于:Redis事务只会保证命令批量执行,但不会帮你做前置的唯一性校验——哪怕users:1已经存在,SET还是会静默覆盖;同理邮箱已存在时HSET也会直接更新值。而且如果没有加锁机制,并发场景下两个客户端同时写入同ID/邮箱,还是会出现数据冲突。
所以解决思路必须满足两个关键点:
- 先校验唯一性,再执行写入,校验不通过就终止操作
- 整个校验+写入过程是原子的,杜绝竞态条件
方案一:WATCH + 事务(原生Redis命令实现)
这个方案用Redis的WATCH命令监听关键key,结合事务实现“校验通过才写入”的逻辑,步骤如下:
具体执行流程
- 监听需要校验的key:先告诉Redis,我要盯着
users:{user_id}和users-indexes:email这两个key,如果它们在事务执行前被其他客户端修改了,就中断我的事务WATCH users:1 users-indexes:email - 手动执行唯一性校验:
- 检查用户ID是否已存在:
EXISTS users:1,返回1说明ID重复 - 检查邮箱是否已存在:
HEXISTS users-indexes:email user-email@gmail.com,返回1说明邮箱重复
- 检查用户ID是否已存在:
- 校验通过则执行事务:如果上面两个检查都返回
0,就开启事务执行写入MULTI SET users:1 "<user encoded as JSON>" HSET users-indexes:email user-email@gmail.com 1 EXEC - 处理事务结果:
- 如果
EXEC返回一个包含两个结果的数组,说明事务执行成功 - 如果
EXEC返回nil,说明WATCH的key被其他客户端修改过(竞态发生),你需要重试整个流程 - 如果校验阶段就发现重复,记得执行
UNWATCH取消监听,避免占用Redis资源
- 如果
优缺点
- ✅ 用原生命令实现,不需要额外学习Lua脚本
- ❌ 需要自己处理重试逻辑,高并发场景下可能会有较多重试次数
方案二:Lua脚本(更简洁的原子操作)
Redis的Lua脚本是原子执行的——脚本里的所有命令会作为一个整体执行,中间不会被其他客户端打断,完美解决竞态问题,而且可以把校验和写入逻辑封装在一起,代码更简洁。
具体脚本实现
-- 参数说明: -- KEYS[1] = 用户ID对应的key,比如"users:1" -- KEYS[2] = 邮箱索引的key,即"users-indexes:email" -- ARGV[1] = 要存储的用户邮箱,比如"user-email@gmail.com" -- ARGV[2] = 序列化后的用户JSON字符串 -- ARGV[3] = 用户ID,比如"1" -- 第一步:检查用户ID是否已存在 if redis.call('EXISTS', KEYS[1]) == 1 then return 0 -- 返回0表示ID重复,操作失败 end -- 第二步:检查邮箱是否已存在 if redis.call('HEXISTS', KEYS[2], ARGV[1]) == 1 then return 0 -- 返回0表示邮箱重复,操作失败 end -- 第三步:执行写入操作 redis.call('SET', KEYS[1], ARGV[2]) redis.call('HSET', KEYS[2], ARGV[1], ARGV[3]) return 1 -- 返回1表示操作成功
调用脚本的命令
EVAL "上面的Lua脚本内容" 2 users:1 users-indexes:email user-email@gmail.com "<user encoded as JSON>" 1
结果处理
- 脚本返回
1:写入成功 - 脚本返回
0:ID或邮箱重复,操作失败 - 完全不需要处理竞态,Redis会保证脚本执行的原子性
额外优化:自动生成唯一用户ID
如果你的用户ID不需要手动指定,可以用自增ID来彻底避免ID重复问题,修改后的Lua脚本如下:
-- 参数说明: -- KEYS[1] = 自增ID的key,比如"users:next-id" -- KEYS[2] = 邮箱索引的key,即"users-indexes:email" -- ARGV[1] = 用户邮箱 -- ARGV[2] = 序列化后的用户JSON字符串 -- 生成唯一用户ID local user_id = redis.call('INCR', KEYS[1]) -- 检查邮箱是否已存在 if redis.call('HEXISTS', KEYS[2], ARGV[1]) == 1 then redis.call('DECR', KEYS[1]) -- 邮箱重复,回滚自增ID return 0 end -- 写入用户数据和邮箱索引 redis.call('SET', 'users:'..user_id, ARGV[2]) redis.call('HSET', KEYS[2], ARGV[1], user_id) return user_id -- 返回生成的用户ID
优缺点
- ✅ 原子性强,无需处理WATCH和重试逻辑
- ✅ 逻辑集中,便于维护和扩展
- ❌ 需要掌握基础的Lua语法,但逻辑非常简单,学习成本低
总结
如果你的业务并发量不高,用WATCH+事务的原生方案足够;如果追求更简洁的实现和更高的并发稳定性,Lua脚本绝对是最优选择。不管用哪种方案,一定要严格处理返回结果——一旦发现重复或事务失败,及时给用户返回错误提示,不能静默忽略。
内容的提问来源于stack exchange,提问作者Hubro
相关产品推荐
相关产品推荐

