Redis 锁、幂等、限流与会话:原子命令能保证到哪里
SET NX 成功不等于业务在整个执行期独占资源;请求 key 存在也不等于第一次执行已经成功。Redis 能把短小状态迁移做成原子操作,却无法证明外部数据库、远程调用和持锁客户端仍然有效。
Redis 官方 Distributed locks 要求锁值唯一并在释放时比较所有者;较新正式线提供 compare-and-delete 命令,旧线通常用短 Lua 完成同一原子判断。
Lua、Pipeline 和客户端缓存:提高效率之前先看边界
Redis 开发里还有三类常见“高级用法”:Pipeline、Lua 脚本和客户端本地缓存。它们都能提效,也都可能把问题放大。
Pipeline:减少 RTT,不提供事务原子性
Pipeline 的作用是把多条命令一次性发给 Redis,最后再批量读取响应。它解决的是网络往返成本,不是原子性。
适合场景:
批量预热缓存。批量读取多个互不依赖的小 key。写入一批统计值或临时 key。
Java 示例:
List<Object> values = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (String orderNo : orderNos) {
byte[] key = ("shop:prod:order:detail:" + orderNo + ":v1").getBytes(StandardCharsets.UTF_8);
connection.stringCommands().get(key);
}
return null;
});常见坑:
把几万条命令塞进一个 pipeline,客户端内存、Redis 输出缓冲和网络包一起变大。在 Cluster 模式下批量 key 跨 slot,客户端行为和性能都可能变复杂。误以为 pipeline 里的命令“一起成功或一起失败”。
排查命令:
CLIENT LIST
INFO clients
CONFIG GET client-output-buffer-limit生产建议:pipeline 批量大小要压测,一般按响应大小和延迟目标分批,而不是只按命令条数分批。需要原子判断时,考虑 Lua、事务或数据库约束,不要靠 pipeline。
Lua:原子执行,但会阻塞主线程
Lua 脚本适合把“读一个 key、判断值、再修改”的短逻辑放到 Redis 内部执行,减少网络往返并保证脚本执行期间不被其他命令插队。
释放锁的旧版本兼容写法:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
endRedis 8.4+ 已经提供 DELEX key IFEQ value 这类条件删除能力,简单锁释放可以优先评估新命令;如果运行环境还在旧版本,再使用 Lua 兼容。
常见坑:
脚本里遍历大集合,阻塞主事件循环。脚本没有版本管理,应用重启、主从切换后 EVALSHA 找不到脚本。脚本里访问的 key 在 Cluster 下不在同一个 slot。
把复杂业务规则写进 Lua,后续难测试、难灰度、难回滚。
生产建议:Lua 只放短小、可证明、可压测的原子片段。超过几十行的业务脚本,一般就该回到应用层或数据库层重新设计。
客户端缓存:本地更快,但失效更难
客户端缓存可以把 Redis 读结果放到应用本地内存里,热点读会更快。Redis 的 client-side caching 支持服务端跟踪 key 并发送失效通知,但这不是默认应该打开的银弹。
适合场景:
配置、字典、规则这类读多写少数据。允许短暂旧值,有版本号或发布号兜底。应用实例数量可控,失效通知和本地缓存容量可观测。
常见坑:
本地缓存、Redis、数据库三层都可能有旧值,排查链路变长。连接断开或失效通知处理异常后,本地缓存可能继续读旧数据。服务端跟踪 key 也有内存成本,不适合无限制缓存所有读过的 key。
生产建议:客户端缓存一定要有最大容量、短 TTL、失效通知失败兜底和一键关闭开关。核心权限、资金、库存状态不要只靠本地缓存判断。
Redisson:用锁可以,但要知道边界
Redis 分布式锁常用于防重复执行、热点重建、定时任务抢占等场景。生产里建议优先使用成熟客户端,不要复制一段不完整的 SETNX 代码到处用。
Redisson 基本写法:
RLock lock = redissonClient.getLock("shop:prod:lock:order:" + orderNo);
boolean locked = lock.tryLock(200, 10, TimeUnit.SECONDS);
if (!locked) {
throw new BusyException("order is processing");
}
try {
processOrder(orderNo);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}参数含义:
waitTime:最多等多久拿锁。leaseTime:拿到锁后多久自动释放。unlock 前用 isHeldByCurrentThread() 校验,避免释放别人的锁。
如果不传 leaseTime,Redisson 的看门狗机制会尝试自动续期,默认看门狗超时时间是 30 秒,可通过 Config.lockWatchdogTimeout 调整。如果传了显式 leaseTime,锁会在指定时间后自动释放,业务执行时间必须小于这个窗口。看门狗能减少业务执行时间不确定导致的锁过期问题,但不代表锁永远安全。长时间 Full GC、网络抖动、Redis 主从切换、业务线程卡死,都可能让锁行为和你想象的不一致。
适合用 Redis 锁的场景:
防止同一个缓存热点被并发重建。防止同一个定时任务多实例同时执行。防重复提交的辅助保护。
冲突代价可控,失败可重试或可降级。
不适合只靠 Redis 锁的场景:
资金、库存等强一致核心状态,没有数据库约束兜底。锁住以后要调用长时间外部流程。业务无法容忍锁丢失、重复执行或短暂并发。
生产建议:关键写入要用数据库唯一键、状态条件或版本号兜底。Redis 锁是减少并发冲突的工程手段,不是业务正确性的唯一凭证。
验证锁是否符合预期,不要只看“代码没报错”。至少做三类实验:
TTL shop:prod:lock:order:ORD001
PTTL shop:prod:lock:order:ORD001
SLOWLOG GET 10业务执行时间超过 leaseTime 时,确认是否会被其他线程抢到锁。模拟持锁线程异常退出,确认锁能自动释放。模拟并发更新,确认数据库唯一键、状态条件或版本号能挡住重复写。
幂等、限流与会话要分别建模
幂等记录至少区分 ABSENT、PROCESSING、SUCCEEDED 和可重试失败,成功结果与业务提交在同一权威边界闭合;Redis 只能做快速门闩时,数据库唯一键仍是最终证据。固定窗口限流在边界突发,滑动窗口或 token bucket 改善平滑度但增加状态成本。会话缓存必须有撤销版本或用户安全纪元,不能只等访问 token 自然过期。
锁租约到期后旧持有者可能继续执行,fencing token 需要由真正写入的权威资源拒绝旧序号;只有 Redis 内部比较 token,保护不了数据库和文件。
用状态模型验证缓存窗口
两个 Java 17 模型不连接真实 Redis,也不复刻 Caffeine 内部结构。它们把本篇必须守住的状态、版本和容量不变量变成确定输出;真实客户端与 Redis 集成测试继续验证协议、超时、拓扑和服务器行为。
javac --release 17 -Xlint:all -Werror examples/backend-development/cache/lock-idempotency-rate-session/LeaseTokenDemo.java examples/backend-development/cache/lock-idempotency-rate-session/IdempotencyStateDemo.java
java -cp examples/backend-development/cache/lock-idempotency-rate-session LeaseTokenDemo
java -cp examples/backend-development/cache/lock-idempotency-rate-session IdempotencyStateDemocurrentOwner=worker-B staleReleaseAccepted=false compareDeleteRequired=true
states=[ABSENT, PROCESSING, SUCCEEDED:result-42] duplicateReturnsStoredResult=true processingLeaseBounded=true缓存正确性不是“命中返回值”,而是命中值仍在允许的陈旧窗口内、加载不会放大到权威存储、旧版本不能覆盖新版本、失败能够在预算内退化。模型输出任一项改变,都应触发对应集成用例,而不是只调整命中率告警。
