Redis 锁、幂等、限流与会话:共享状态的设计与实现
商品标题缓存丢失后,可以从数据库重建。锁、请求处理记录、访问配额和登录会话则直接参与业务决策:一个键提前消失,可能让另一个任务进入临界区、让重复请求再次执行,或让用户重新登录。
这些用途都能使用 Redis,但需要分别定义状态怎样建立、谁能修改,以及状态丢失以后怎样处理。
四类共享状态分别记录什么
从键值含义开始设计
| 用途 | 键中保存的内容 | 写入成功后的含义 | 状态消失后的处理 |
|---|---|---|---|
| 租约锁 | 当前持有者的随机标识、有效期限 | 在该 Redis 协调范围内获得一个有期限的资格 | 旧任务停止或由受保护资源拒绝;其他任务可以竞争 |
| 幂等记录 | 请求身份、输入摘要、处理状态和结果 | 具体含义取决于记录处于处理中还是已完成 | 按保留规则查询持久记录或重新处理 |
| 限流状态 | 时间窗口、令牌余额或请求时间 | 消耗或拒绝一次配额 | 依预定策略恢复额度、拒绝或降级 |
| 会话 | 用户关联、登录状态、期限与撤销信息 | 服务端可以识别这份会话 | 要求重新认证或拒绝当前请求 |
一个键的 TTL 应当与它的业务含义一致。锁的 TTL 是租约期限;会话的 TTL 可以表示空闲期限;商品缓存的 TTL 表示副本保留时间。把所有键统一设置为十分钟,会混淆这些完全不同的规则。
支付执行记录、库存扣减记录等关键事实,通常需要放在能与业务变更共同提交的持久存储中。Redis 可以加速查询已完成的结果,或减少重复任务同时进入,但最终是否已经执行过,必须有可恢复的依据。
共享状态需要独立的内存和恢复策略
纯缓存可以采用 allkeys-lru 等策略淘汰旧条目。若锁键和幂等记录也放在同一个可驱逐实例中,内存压力就可能提前移除仍有业务意义的状态。
按用途隔离实例或服务资源,比只换一个键前缀更能隔离容量、驱逐和故障影响。Redis 的逻辑数据库编号也不能隔离同一进程的内存与执行资源。驱逐行为见 Key eviction。
选择 noeviction 后,还要处理内存不足导致的写入拒绝。关键状态写不进去时,应用不能继续假设“锁已经拿到”或“幂等记录已经保存”。容量、复制、持久化和恢复演练共同决定这类状态能承担什么业务用途。
从原子命令到业务约束
租约取得与释放
一次 SET 可以同时表达“键不存在才写入”和“设置有效期”:
SET lock:product:42 <本次持有者随机标识> NX PX 5000成功返回 OK;没有取得锁时返回空结果。不要拆成 SETNX 和 PEXPIRE 两条命令:进程可能在两条命令之间退出,留下没有期限的锁。参数语义见 SET。
持有者标识应为每次成功竞争生成的随机值,而非固定的主机名或线程名。释放时,必须同时比较当前值和删除键。
Redis 8.4 起提供 DELEX:
DELEX lock:product:42 IFEQ <本次持有者随机标识>返回 1 表示删除了匹配的键,0 表示没有匹配并删除。这里使用的 Redis 8.10.1 支持该命令;完整条件语法见 DELEX。
兼容旧版本时,可以在一个短 Lua 脚本内完成比较和删除:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0若先 GET,再由客户端判断并发送 DEL,两条命令之间可能发生租约过期和新持有者进入。Lua 将这段判断放到服务端连续执行,避免误删新持有者的锁。执行规则见 Scripting with Lua。
过期后的旧任务仍可能继续运行
任务 A Redis 任务 B
取得 owner-A ────────────→ 租约有效
长时间暂停 owner-A 到期
owner-B 写入 ←────────── B 取得新租约
恢复运行
尝试释放 owner-A ────────→ 比较不匹配,保留 owner-B
此时还需回答:A 恢复后向数据库发出的旧写入怎样处理?比较删除解决误释放。要限制旧任务继续修改外部资源,受保护的数据库、存储服务或其他目标还需要自己的并发规则。
一种做法是 fencing:协调协议为每次有效持有资格分配递增代次,任务把代次一同交给受保护资源。资源保存已经接受的代次,并拒绝更旧的请求。若资源不检查这个字段,客户端携带数字没有实际保护作用。
对每个资格只接受一次更新的简化 SQL 如下:
UPDATE guarded
SET fence = :incoming_fence, content = :new_content
WHERE id = :id AND fence < :incoming_fence;更新行数为 1 表示本次代次被接受;0 表示条件不成立,还需区分资源不存在与代次已过期。同一代次内需要多次操作的协议,必须另行定义相等代次的接受方式和操作幂等性。
实验使用 Redis INCR 生成两个数字,再让 H2 实际执行条件更新。它展示资源端如何拒绝旧数字;生成数字与取得租约并没有组成完整的协调协议。实际方案还必须保证代次分配与持有资格正确关联,并考虑序列在故障切换、恢复备份后的连续性。INCR 的命令行为见 INCR。
Redis 官方的 Distributed locks 进一步讨论唯一持有者标识、有效时间、时钟假设和多节点算法。业务若要求外部资源严格串行,应同时评估数据库条件更新、唯一约束或具有相应一致性保证的协调服务。
使用 Redisson 时仍要明确租约方式
Redisson 的锁接口封装了获取、释放与续约。未指定固定 leaseTime 的相应用法可由 watchdog 延长锁的期限;显式 leaseTime 则规定自动到期时间。默认 watchdog timeout、线程持有者检查及 RFencedLock 的用法见 Locks and synchronizers。
续约需要客户端能够及时运行并与 Redis 通信。长暂停、网络中断或进程退出都可能导致续约停止。应选择可取消的任务、明确的执行期限,并让外部写入有自己的保护方式。增加租约时间主要改变等待和误过期的权衡,无法让未知时长的任务天然安全。
拿锁请求本身超时时,也可能存在“服务端已保存锁、客户端未收到答复”的情况。此时不要直接进入临界区;根据协议确认本次持有者状态,或等待已知租约结束后重新竞争。
幂等记录与业务结果共同提交
幂等处理需要识别“同一个请求”。常用身份由调用主体、业务操作和客户端提供的请求键共同组成;输入摘要用于检测同一键是否被用于不同内容。
第一次请求
└── 在同一数据库事务内
├── 插入唯一请求记录
├── 执行业务变更
├── 保存可重放结果
└── 提交
重复请求
├── 同一身份、相同输入、已有结果 → 返回已保存结果
├── 同一身份、输入不同 → 拒绝复用
└── 首次执行仍未完成 → 等待、查询状态或明确返回处理中用 Redis SET NX 记录“见过这个请求”,随后调用支付接口,是两个独立动作。如果进程在支付成功后、写入完成状态前退出,重试仍面临结果未知。应利用支付服务自己的幂等键和查询接口;跨系统步骤还需要可重试的状态流转与对账。
同一数据库内,可以用唯一约束和事务把请求记录与业务变更绑定。发生唯一冲突后,需要先结束失败事务,再读取已提交的记录;不同数据库对事务错误后的处理规则可能不同。Spring 的事务回调用法见 Programmatic transaction management。
实验固定在单一业务范围内,用 request_key 唯一约束保存输入和结果。完整 HTTP 示例中,请求头、冲突响应、并发请求及重放结果的处理见 API 版本、分页与幂等审计。
保留期限也属于接口约定。若只保留一天,第二天重放同一键可能再次产生业务变更。应根据最大重试周期、对账周期和业务不可重复要求决定记录何时能够归档或移除。
限流先确定额度的维度和时间算法
| 算法 | 主要状态 | 特点 |
|---|---|---|
| 固定窗口 | 窗口内计数 | 实现简单,窗口相邻处可能集中通过两批请求 |
| 滑动窗口日志 | 保留窗口内请求时间 | 可以精确计算最近一段时间,存储与清理成本较高 |
| 分段滑动计数 | 多个时间桶计数 | 控制状态规模,以分段粒度近似滑动范围 |
| 令牌桶 | 余额、补充速率、更新时间 | 允许一定突发,同时限制长期速率 |
| 并发限制 | 当前执行中的许可数 | 限制同时占用资源的任务数量 |
按 IP 限流可能把 NAT 后的多个用户合并;按用户限流依赖可信身份;按接口限流需要考虑不同操作的成本。高风险操作常同时设置主体、资源和总体配额。
固定窗口可以通过短 Lua 将计数和首次设置 TTL 放在一起。下面的核心用于受控实验,窗口为从第一次请求开始的 30 秒,最多接受 5 次,所有尝试都参与计数:
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
if count <= tonumber(ARGV[2]) then
return 1
end
return 0ARGV 由服务端配置传入固定正整数,键类型也由这一用途独占。对外部配置或动态参数,应在写入前完成数值范围、类型和窗口合法性校验;Lua 后续报错不会回滚已经执行的 INCR。
窗口到期后,下一次请求建立新窗口。若要按自然分钟计数,键中还要体现统一的窗口编号;多键脚本部署在 Redis Cluster 时,相关键需要位于同一 hash slot。
限流服务不可用时,是拒绝请求、使用本地应急额度,还是暂时放行,应按操作风险分别配置。每个实例各发放一份本地额度时,总额度会随实例数增加。
会话同时管理空闲期限和绝对期限
会话 ID 应使用不可预测的随机值,不在日志、URL 或错误信息中暴露。服务端可用令牌摘要索引会话数据,保存用户关联、绝对到期时间和必要状态。
空闲期限随着有效活动延后;绝对期限限制一次登录持续的总时长。续期时应采用:
新的剩余 TTL = min(空闲期限, 绝对到期时刻 - 当前时刻)如果只在每次请求后重新设置完整 TTL,持续访问就可能让会话长期不结束。注销、密码修改或其他风险事件也需要相应的服务端失效措施。
浏览器端应使用 HTTPS,并配置合适的 Secure、HttpOnly、SameSite 和 Cookie 作用域。SameSite 是跨站请求防护的一部分,不能替代需要的 CSRF 校验;HttpOnly 也不能阻止页面内恶意脚本借用户身份发起请求。会话生成、轮换、到期与撤销建议见 OWASP Session Management。
Redis 会话不可达与会话不存在应分别记录。前者通常对应服务不可用或认证依赖故障,后者才是没有可用会话;都不应自动授予权限。
运行六项共享状态实验
环境、身份与执行方式
下载 缓存实验工程。在普通 Linux 用户下,按 工程准备取得 PROJECT_DIR、LAB_DIR,并按 Redis 网络准备启动专用 ca11-redis。
环境使用 Docker、Bash、Maven 3.9.12、Java 25 或 17,以及 Redis 8.10.1。数据库由测试创建独立 H2 内存实例。Redis 位于不发布宿主端口的 ca11-lab internal 网络;无认证、无持久化配置仅用于这一隔离实验。
test "$(id -u)" -ne 0 || exit 1
docker exec ca11-redis redis-cli PING
docker run --rm --name ca11-state-build \
--network ca11-lab --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/maven \
-e REDIS_URI=redis://ca11-redis:6379 \
-v "$PROJECT_DIR:/work" -v "$LAB_DIR/m2:/cache" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -o -Duser.home=/tmp -Dmaven.repo.local=/cache \
-Dtest=RedisStateTest testPING 应返回 PONG,Maven 应报告 6 个测试通过和 BUILD SUCCESS。Java 17 将镜像换成 maven:3.9.12-eclipse-temurin-17,使用同一工程重跑。离线依赖缺失时,回到工程准备阶段补齐依赖,再进入 internal 网络执行。
测试仅接受固定实验地址,键带随机前缀,由创建它们的测试清理;不执行 FLUSHALL。
观察租约与旧持有者
expiredOwnerDelete=0 newOwnerRetained=true matchingOwnerDelete=1
luaWrongOwner=0 luaMatchingOwner=1第一个实验让 A 的 150 毫秒租约实际到期,再由 B 写入五秒租约。A 用自己的标识调用 DELEX,返回 0;B 的值仍可读。随后 B 正确释放,返回 1。
第二个实验使用 Lua 比较删除,分别检查错误持有者和匹配持有者。若 DELEX 报 UNKNOWN COMMAND,先检查目标版本,旧服务器可运行 Lua 方案;不要替换成普通 DEL。
让数据库拒绝旧代次
newFenceUpdate=1 staleFenceUpdate=0 protectedValue=new-owner两个代次来自实际 Redis INCR。H2 先接受较新代次的更新,再拒绝旧代次,最终内容仍为 new-owner。断言检查的是 SQL 更新行数和实际数据库值。
这一实验没有执行多节点选主或备份恢复。需要这些保证时,应测试所选协调系统的故障行为,以及受保护资源对代次的完整处理规则。
检查幂等结果与回滚
sameInputReplays=true changedInputRejected=true businessApplied=1 failedReceiptRolledBack=true首次调用同时插入请求记录并增加商品 revision。相同输入重试返回相同结果,数据库只应用一次业务变更;改变输入后复用同一键会被拒绝。
另一个请求在事务提交前抛出异常。事务结束后,其请求记录不存在,revision 也没有增加。可将失败位置与数据读取一起查看,理解事务怎样覆盖两张表的变化。
并发计数与会话到期
fixedWindowConcurrentCalls=20 accepted=5 rejected=15 ttlPresent=true
activeReadsCannotExtendAbsoluteExpiry=true revokedSession=absent tokenNotLogged=true20 个线程同时调用实际 Lua 限流脚本;最后 Redis 计数为 20,接受 5 次,拒绝 15 次,并保留有效 TTL。
会话实验使用 SecureRandom 生成 32 字节随机令牌,以摘要组成键。空闲期限为五秒、绝对期限为 1.5 秒,测试持续访问,最终仍达到绝对期限并失效;随后重新建立测试会话,删除后立即读不到用户关联。时间取自 Redis TIME,避免在这段脚本中混用不同客户端时钟,命令见 TIME。
这里运行的是服务端状态读写,没有启动浏览器认证流程。接入 Web 应用时,还需由安全框架处理认证、Cookie、CSRF 和风险事件。
故障时先判断哪份状态失效
锁问题看持有者与资源端结果
| 现象 | 先查什么 | 下一步 |
|---|---|---|
| 锁长期占用 | TTL、持有者任务、续约状态 | 确认任务是否仍有效,再按协调协议恢复 |
| 旧任务覆盖新结果 | 租约是否已过期、资源是否检查版本或代次 | 修正资源端条件,处理已产生的业务结果 |
| 释放返回 0 | 当前锁值是否已变化或到期 | 停止使用旧资格,不盲目 DEL |
| 获取锁超时 | 本次标识是否被保存、调用是否已结束 | 查询或等待租约,避免未知状态下执行 |
| 扩容后互斥失效 | 是否连接同一协调范围、键维度是否一致 | 核对配置、主节点与业务资源 ID |
不要通过手工删除未知锁来“恢复成功率”。先确认它保护的任务和资源,否则可能在原任务仍执行时放入第二个任务。
幂等问题检查提交位置与保留规则
相同请求执行两次,可能是幂等键维度错误、记录过早过期,也可能是记录和业务变更分开提交。应按请求身份查询持久记录、业务表和下游执行结果,确定重复发生在哪一步。
记录为“处理中”却长期没有进展时,需要定义接管条件。仅凭锁 TTL 到期重新执行外部支付,仍可能重复扣款。接管前应查询外部系统,复用其幂等键,或将结果未知的操作交给对账流程。
限流和会话要区分缺失与依赖故障
限流持续拒绝时,先检查计数键 TTL、主体维度、窗口计算和重试流量。不断刷新同一个窗口的完整 TTL,可能让一个繁忙主体迟迟等不到配额恢复。
用户集中退出登录时,检查会话驱逐、Redis 重启、失效通知及绝对期限配置;不要只延长 TTL 掩盖状态丢失。认证依赖恢复后,也不应凭客户端旧令牌重建已被撤销的会话。
对这些共享状态,应分别监测写入拒绝、操作超时、提前消失、重复执行和撤销传播。常规命中率只能反映部分读路径,无法替代业务结果核对。
权威资料与规范地址
按命令语义、协调算法、事务和会话安全查阅;版本敏感的行为应与实际服务端和客户端依赖对应。
