一、redis 原子性操作的本质:为什么 redis 能保证原子性?
首先需要明确一个关键概念:redis 的原子性是指单个命令的执行是 "不可中断" 的—— 当一个命令开始执行后,直到其执行完毕,redis 不会中断它去执行其他命令。这种特性并非 redis 独创,而是基于其单线程模型的天然优势。
1.1 底层原理:单线程模型 + 命令队列
redis 采用单线程事件循环模型处理客户端请求,这种设计架构主要由以下几个关键组件构成:
- i/o 多路复用:redis 使用 epoll/kqueue/select 等系统调用来高效处理大量网络连接
- 命令队列:所有客户端请求都会被序列化到一个全局内存队列中
- 单线程事件循环:主线程按 "先进先出(fifo)" 的顺序从队列中取出命令执行
这种设计从根本上保证了:
- 命令执行的独占性:每个命令在执行期间独占 cpu 资源
- 状态一致性:命令执行的结果不会出现 "部分完成" 的中间状态
- 操作完整性:完整的操作序列不会被其他命令打断
典型应用场景示例: 当执行 incr key 命令时,redis 会严格按照以下顺序完整执行:
- 从内存中读取 key 的当前值(假设为 5)
- 在 cpu 寄存器中执行加 1 操作(5 → 6)
- 将新值(6)写回内存
- 返回结果给客户端
在此期间,即使有 100 个客户端同时发送 incr 命令,redis 也会将它们排队处理,确保每个 incr 操作都能正确累加。
1.2 原子性的边界:单个命令 vs 多个命令
需要特别注意的是:redis 仅保证 "单个命令" 的原子性,多个命令的组合并不天然具备原子性。理解这一点对设计可靠的 redis 应用至关重要。
典型问题示例
# 以下两个命令组合不具备原子性 get key1 # 步骤1:读取key1 set key2 value2 # 步骤2:写入key2
潜在风险场景:
- 客户端a执行
get key1获取值为 100 - 此时客户端b修改了 key1 的值为 200
- 客户端a继续执行
set key2 value2 - 结果:客户端a基于已过期的 key1 值做出了错误决策
解决方案对比
| 方案 | 实现方式 | 适用场景 | 性能影响 |
|---|---|---|---|
| 事务(multi/exec) | 将多个命令打包执行 | 简单的命令组合 | 中等,需要排队 |
| lua脚本 | 原子执行复杂逻辑 | 需要条件判断的业务 | 较高,需要解析脚本 |
| watch | 乐观锁机制 | 需要检测变化的场景 | 较高,可能重试 |
lua 脚本示例:
-- 原子性地检查并设置值
if redis.call("get", keys[1]) == argv[1] then
return redis.call("set", keys[2], argv[2])
else
return 0
end
最佳实践建议:
- 对于简单的计数器场景,优先使用原生原子命令(incr/decr 等)
- 需要组合不超过5个命令时,使用 multi/exec 事务
- 复杂业务逻辑(包含条件判断)必须使用 lua 脚本
- 对性能敏感的场景,提前测试不同方案的 qps 表现
二、redis 核心原子操作分类与实践
2.1 基础数据结构的原子操作
数据结构详解与扩展应用场景
string类型
setnx命令扩展应用:- 实现分布式锁的基础原语
- 用户首次登录初始化配置
- 防止缓存击穿(当缓存失效时,只允许一个请求去查询数据库)
getset典型使用场景:- 系统维护状态切换(获取当前状态并更新为新状态)
- 实现简单的消息队列(配合
lpush使用)
hash类型
hset高级用法:- 用户会话管理(存储多个会话属性)
- 商品详情缓存(避免序列化/反序列化整个对象)
hincrby实际案例:- 电商平台商品库存扣减(保证库存准确性)
- 论坛帖子点赞计数
list类型
- 高级队列模式:
- 阻塞式队列(
blpop/brpop) - 循环队列(
lindex+lpush)
- 阻塞式队列(
- 典型应用:
- 最新消息展示(固定长度列表)
- 任务调度系统
- 高级队列模式:
set类型
- 扩展功能:
- 共同好友计算(
sinter) - 数据去重处理
- 共同好友计算(
- 实际案例:
- 用户标签系统
- 抽奖活动参与者管理
- 扩展功能:
zset类型
- 高级应用:
- 延迟队列(使用时间戳作为score)
- 热点数据统计
- 典型场景:
- 游戏排行榜
- 优先级任务调度
- 高级应用:
用户登录状态存储的进阶实现
// 高级登录状态管理
public boolean setloginstatus(string userid, string deviceid) {
string key = "user:" + userid + ":session";
string value = deviceid + ":" + system.currenttimemillis();
// 使用set命令的完整参数
string result = jedis.set(key, value,
"nx", // 仅当key不存在时设置
"ex", // 设置过期时间单位秒
3600, // 1小时过期
"get" // 返回旧值(如果存在)
);
if (result != null) {
// 处理旧设备踢出逻辑
handleolddevicelogout(result);
}
return "ok".equals(result);
}
2.2 计数器与自增操作
incr系列命令的底层原理
redis实现原子自增的方式:
- 单线程模型保证命令串行执行
- 内存操作避免磁盘i/o延迟
- 特殊编码优化(当值较小时使用更紧凑的存储格式)
计数器的高级应用模式
滑动窗口限流
-- lua脚本实现滑动窗口限流 local current_time = redis.call('time')[1] local window_size = 60 local max_requests = 100 local key = keys[1] -- 清除过期记录 redis.call('zremrangebyscore', key, 0, current_time - window_size) -- 获取当前请求数 local count = redis.call('zcard', key) if count >= tonumber(argv[1]) then return 0 end -- 添加当前请求记录 redis.call('zadd', key, current_time, current_time..math.random()) redis.call('expire', key, window_size) return 1分布式id生成器
// twitter的snowflake算法变种实现 public long generateid(string biztype) { string key = "id_generator:" + biztype; long timestamp = system.currenttimemillis(); // 获取序列号并自增 long sequence = jedis.incr(key); jedis.expire(key, 3600); return ((timestamp - 1288834974657l) << 22) | (datacenterid << 17) | (workerid << 12) | (sequence % 4096); }精确计数与基数统计
- 小数据量:直接使用incr
- 大数据量:结合hyperloglog进行基数估算
2.3 分布式锁的完整实现方案
分布式锁的演进过程
基础版本
set lock:resource unique_value nx ex 30
改进版本(解决锁续期问题)
// 加锁 string result = jedis.set(lockkey, requestid, "nx", "px", expiretime); // 启动守护线程定期续期 new thread(() -> { while (locked) { jedis.expire(lockkey, expiretime/1000); thread.sleep(expiretime/3); } }).start();redlock算法实现
// 多节点加锁 list<jedis> jedislist = getredisnodes(); int successcount = 0; long starttime = system.currenttimemillis(); for (jedis jedis : jedislist) { if (jedis.set(lockkey, value, "nx", "px", expiretime) != null) { successcount++; } } // 检查是否在大多数节点上加锁成功 boolean locked = successcount >= (jedislist.size()/2 + 1);
生产环境最佳实践
锁粒度控制
- 细粒度锁:按业务id拆分(如order:123)
- 粗粒度锁:全局资源保护
异常处理
try { if (acquirelock()) { // 业务逻辑 } } finally { // 确保释放锁 releaselock(); }性能优化
- 避免长时间持有锁
- 使用trylock模式(带超时)
- 锁分段技术提升并发
锁监控
# 监控锁状态 redis-cli --latency -h 127.0.0.1 -p 6379 redis-cli slowlog get
集群环境特殊考量
主从切换问题
- 使用redlock算法
- 监控主从同步延迟
多数据中心部署
- 跨机房延迟评估
- 本地缓存与分布式锁结合
锁服务降级方案
- 本地锁降级
- 乐观锁替代
- 熔断机制
三、多命令原子性实现:事务与 lua 脚本
当需要多个命令组合实现原子性时,redis 提供了两种方案:multi/exec事务和lua 脚本。下面对比两者的差异与适用场景。
3.1 multi/exec 事务:弱一致性的批量执行
redis 事务并非传统数据库的 acid 事务,其核心特性是 "批量执行 + 要么全部执行,要么全部不执行"(但不支持回滚)。
事务执行流程详解
- multi:标记事务开始,后续命令进入队列
- 命令入队:所有操作命令不会被立即执行,而是返回"queued"状态
- exec:执行所有队列中的命令
- discard:可选操作,用于取消事务
127.0.0.1:6379> multi # 开启事务 ok 127.0.0.1:6379> incr counter:user # 命令1:用户数+1 queued # 命令入队,未执行 127.0.0.1:6379> set user:1002:status "active" # 命令2:设置用户状态 queued 127.0.0.1:6379> exec # 执行事务,所有命令原子性执行 1) (integer) 101 # incr命令结果 2) ok # set命令结果
事务的局限性详解
不支持回滚:
- 语法错误:事务中某个命令语法错误(如错误的命令名),整个事务都不会执行
- 运行时错误:如对字符串执行incr操作,错误命令会失败,但其他命令仍会执行
弱隔离性:
- 事务执行期间会阻塞其他客户端命令
- 但事务内的命令是"非原子性入队"的(即入队时不执行,执行时才获取数据)
- 可能出现"watch"失效问题
无法处理并发冲突:
- 没有类似数据库的乐观锁机制
- 两个事务同时修改同一key时,后执行的会覆盖先执行的结果
适用场景
- 需要批量执行多个命令,且不要求严格的事务隔离性
- 简单的计数器更新、状态标记等场景
- 配合watch实现简单的乐观锁控制
3.2 lua 脚本:强一致性的原子执行
redis 支持通过 lua 脚本执行自定义逻辑,且整个 lua 脚本的执行过程是原子性的—— 脚本执行期间,redis 不会中断或执行其他命令。这使得 lua 脚本成为实现复杂原子逻辑的最佳选择。
lua 脚本的核心优势
完整的原子性:
- 脚本作为一个整体执行,不会被其他命令打断
- 所有操作要么全部成功,要么全部失败
丰富的逻辑控制:
- 支持条件判断(if...else)
- 支持循环(for/while)
- 支持变量和复杂计算
网络效率高:
- 多个命令打包成脚本,只需一次网络往返
- 特别适合高延迟环境
实践案例:库存扣减(避免超卖)
某商品库存初始值为 100,需实现 "用户下单时原子性扣减库存,库存不足时返回失败":
-- lua脚本:keys[1]为库存key,argv[1]为扣减数量
local stock = redis.call('get', keys[1])
if not stock or tonumber(stock) < tonumber(argv[1]) then
return 0 # 库存不足,扣减失败
end
return redis.call('decrby', keys[1], argv[1]) # 原子性扣减库存
java调用示例:
string luascript = "local stock = redis.call('get', keys[1])\n" +
"if not stock or tonumber(stock) < tonumber(argv[1]) then\n" +
" return 0\n" +
"end\n" +
"return redis.call('decrby', keys[1], argv[1])";
list<string> keys = collections.singletonlist("stock:goods:1001");
list<string> args = collections.singletonlist("1");
// 执行脚本
long result = (long) jedis.eval(luascript, keys, args);
if (result == 0) {
system.out.println("库存不足");
} else {
system.out.println("库存扣减成功,剩余库存:" + result);
}
脚本缓存优化
redis会缓存执行过的脚本(通过sha1校验和),后续可通过evalsha调用:
# 首次执行
127.0.0.1:6379> script load "return redis.call('get', keys[1])"
"a5a06e6a8a4b4a5a5a5a5a5a5a5a5a5a5a5a5a5"
# 后续执行
127.0.0.1:6379> evalsha a5a06e6a8a4b4a5a5a5a5a5a5a5a5a5a5a5a5 1 mykey
"value"
适用场景
- 需要严格原子性的复杂操作(如库存扣减、秒杀)
- 需要条件判断的多步骤操作
- 高频操作需要减少网络开销的场景
- 分布式锁的实现(包含锁的获取、续期和释放)
注意事项
- 脚本执行时间不宜过长(默认5秒超时)
- 避免在脚本中执行耗时操作
- 脚本应保持简单,避免复杂计算
四、redis 原子操作的常见问题与避坑指南
即使掌握了原子操作的用法,在实际开发中仍可能因细节处理不当导致问题。下面总结 4 个高频坑点及解决方案,并提供具体优化建议。
4.1 坑点 1:混淆 "单命令原子性" 与 "多命令原子性"
问题现象: 在电商秒杀场景中,开发者错误地认为多个独立命令的组合具有原子性。例如以下库存扣减逻辑:
# 错误示例:判断库存>0后扣减(非原子操作)
if redis.call('get', 'stock:1001') > 0 then
redis.call('decr', 'stock:1001') # 可能出现并发时库存为负
end
问题原因:
get和decr是两个独立命令,中间可能插入其他请求- 在高并发场景下,多个请求可能同时判断库存为正,导致"超卖"现象
解决方案:
- 使用 lua 脚本将"判断+扣减"封装为原子操作(完整示例见3.2节)
- 或者直接使用
decr命令的返回值判断(返回减后的值,若为负则不允许扣减)
4.2 坑点 2:分布式锁未设置过期时间
典型场景: 在分布式任务调度系统中,使用redis实现分布式锁时出现以下问题:
# 错误加锁方式(未设置过期时间) set lock:order_123 true nx
风险分析:
- 若客户端崩溃或网络异常,锁将永远无法释放
- 其他客户端将无法获取锁,导致系统死锁
- 需要人工介入删除key才能恢复
最佳实践:
- 必须使用带过期时间的加锁命令:
set lock:order_123 true nx ex 10
- 过期时间设置原则:
- 大于业务执行的最大耗时(如业务最多执行5秒,设10秒)
- 建议设置自动续期机制(如redisson的watchdog)
- 配合唯一标识实现安全解锁:
if redis.call("get",keys[1]) == argv[1] then return redis.call("del",keys[1]) else return 0 end
4.3 坑点 3:lua 脚本执行效率低下
性能问题案例: 某社交平台在用户feed流处理脚本中,包含以下低效操作:
-- 低效脚本示例:遍历所有粉丝进行计数
local followers = redis.call('smembers', 'user:'..userid..':followers')
local count = 0
for i, follower in ipairs(followers) do
count = count + redis.call('scard', 'user:'..follower..':posts')
end
return count
影响分析:
- redis单线程模型下,脚本执行会阻塞其他命令
- 当粉丝量达百万级时,脚本执行可能超过1秒
- 导致redis整体吞吐量下降,qps骤降
优化建议:
- 脚本优化原则:
- 避免大数据集遍历(改用scan分批处理)
- 复杂计算移到客户端(如排序、聚合)
- 单个脚本执行时间控制在10ms内
- 改进方案:
- 使用redis的
scard命令直接获取集合基数 - 或改用客户端分批查询后聚合
- 使用redis的
4.4 坑点 4:使用 incr 实现分布式 id 时的溢出问题
问题背景: 某物联网平台使用redis生成设备id:
incr device:id_counter
潜在风险:
- redis计数器最大值为2^63-1(约9e18)
- 假设每天生成1亿id,约需2.5亿年才会溢出
- 但某些高频场景(如日志id)可能快速耗尽
解决方案:
- 组合id生成方案:
# 时间戳(41bit) + 机器id(10bit) + 序列号(12bit) incr id:20230101 # 每日重置计数器
- 定期重置机制:
expire id_counter 86400 # 每日自动过期
- 分片方案:
incr id_counter:{shard1} # 按业务分片使用不同key
监控建议:
- 对关键计数器设置监控告警
- 当计数值超过阈值时自动告警
- 定期检查计数器增长趋势
到此这篇关于redis 的原子性操作的文章就介绍到这了,更多相关redis 原子性操作内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论