分布式锁、Session 与调度:租约和重复执行
任务 A 获得一把有期限的锁,随后进程暂停。锁过期后,任务 B 接手并写入结果;A 恢复执行时,内存里还保存着“已经获得锁”的判断。如果目标只接受普通 UPDATE,A 的迟到写仍能覆盖 B。
协调存储判定当前持有者,业务资源检查迟到请求是否仍有写入资格。Session 并发保存与调度重复执行也会遇到类似的先后交错,各自通过相应的状态和约束处理。
锁与租约怎样工作
本地互斥不能覆盖其他进程
Java 的 synchronized、ReentrantLock 只协调共享同一个内存对象的线程。把应用部署成三个实例后,每个实例都能独立获得自己的锁。跨进程互斥需要共同访问一个协调位置,例如数据库行、Redis 键或基于共识的锁服务。
锁解决的是受约束调用之间的互斥。业务记录若允许别的脚本绕过锁直接写入,这些修改仍会发生;同一个订单的重复扣款,还需要业务唯一键或幂等记录。性能优化用的缓存重建锁和资金结算用的正确性约束,应采用不同的失败处理要求。
token、TTL 和续期
租约允许某个持有者在有限时间内使用资源,避免进程永久失联后无人接替。一条常见锁记录可以拆成:
key:需要协调的资源,例如 report:tenant-42
├── owner token:本次获取的随机标识,用于安全释放
├── TTL / expiresAt:有效期限
└── epoch / fence:若协议需要,表示不同任期的先后随机 token 区分“是不是这一次获取”;递增 fence 比较“哪一次更新”。二者不能互换。持有者 ID 可以长期稳定,但每次重新获取锁应产生新的 token,否则旧释放请求可能删掉自己后来取得的锁。
续期需要确认记录仍属于本次获取,再原子延长期限。网络超时时,客户端可能不知道续期是否成功;应停止开启新的受保护工作,按协议处理正在执行的请求。仅在任务开始前检查一次 TTL,无法阻止检查后发生长时间暂停。
Redis 的原子获取与比较删除
单实例 Redis 可使用 SET key token NX PX ttl:键不存在时建立有期限的记录。释放时比较 token 与当前值,再执行删除。Redis 8.4 起提供 DELEX key IFEQ token;旧版本可以用 Lua 将比较与删除放在同一次执行中。Redis 分布式锁
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0先 GET、再由客户端执行 DEL 会留下并发窗口:GET 之后 A 的锁过期,B 建立新锁,A 的 DEL 便删掉 B。Lua 或比较删除只保证这次释放不误删,业务资源的迟到写需要另行约束。
Redis 主从复制的异步切换可能丢失最近的锁写入:A 已获得锁,新主却看不到它,于是 B 也获得锁。多节点锁算法还依赖所采用的时钟、通信和失败假设,不能把“用了多个 Redis”直接解释成所有故障下的互斥。严格保护单库业务时,通常优先使用该数据库自己的条件更新、唯一约束或事务锁。
基于会话或共识的锁
etcd 的 Lock 服务用与租约关联的键表示所有权。租约到期会释放相应键,客户端可把锁键的存在条件与 etcd 中的业务写放入事务比较;接口见 etcd concurrency API。
如果实际写入目标是另一个数据库或对象存储,etcd 的事务比较就无法自动覆盖那次写入。需要目标支持 fence、版本条件或其他独立保护。Watch 的删除通知也可能晚于租约失效,不能依靠“客户端尚未收到通知”延长写权限。
让目标拒绝迟到写入
fence 必须被实际写入口检查
假设 A 的任期为 41,B 的任期为 42。目标保存已接受的最大 fence:
UPDATE lease_lab.resource
SET value = :new_value, max_fence = :fence
WHERE id = 1 AND max_fence < :fence;示例一次任期只提交一个结果,因此使用严格小于。需要同一任期多次写入时,还要约定相等 fence 的行为、操作序号与重复请求,不能直接照搬这条单结果 SQL。
协调存储:A / 41 ──租约失效──→ B / 42
业务资源:初始 fence 0
├── 接受 B(42) → 保存 fence 42
└── 拒绝 A(41) → 更新行数 0,保留 B 的值目标保存最大 fence 的方式有一个重要条件:它在已经接受 42 之后拒绝 41。若 A 的租约已过期,但 B 尚未向目标写入 42,目标仍可能接受 41,因为它没有查询租约是否有效。需要严格按当前租约授权的系统,应把租约检查与写操作放到同一个可原子判断的位置,或在新任期启用前先更新目标的授权状态。
fence 的单调性还必须跨故障恢复成立。异步复制的 Redis INCR 或恢复旧快照,可能使计数器退回过去的值;新的持有者因此不一定拥有真正更新的任期。重新创建集群或从备份恢复时,任期命名空间也要与已有目标记录协调。
同库事务可以直接保护写入
租约表与业务表在同一个数据库时,可以先锁住租约行,检查 owner、epoch 和到期时间,再在同一事务中更新业务表。所有领取者也必须更新这条受锁保护的记录:
BEGIN;
SELECT owner FROM lease_lab.lease
WHERE id = 1 AND epoch = :epoch
AND expires_at > clock_timestamp()
FOR UPDATE;
-- 应用确认返回 owner 与本次请求一致。
UPDATE lease_lab.resource SET value = :new_value WHERE id = 1;
COMMIT;示例 guardedStore 在没有合法 owner 时回滚并返回 false。即使事务执行过程中跨过期限,新领取者也要等这笔持锁事务完成,业务写的先后由数据库序列化。这里保护的是同库操作次序,并没有承诺长事务在某个绝对时刻被强制终止。
持锁期间不调用外部接口,给事务设置超时,避免把短协调操作扩展成长期阻塞。PostgreSQL 的行锁冲突和事务结束释放规则见 显式锁。
很多场景甚至不需要独立锁表。库存可以用 UPDATE ... WHERE available >= quantity 原子扣减,单次结算可以用唯一业务键,普通编辑可以用 WHERE version = expectedVersion。条件更新后检查行数,零行进入冲突处理;语义见 PostgreSQL UPDATE。
运行旧持有者、Session 和调度实验
下载 分布式状态实验工程。Linux 宿主需要 Docker Engine、Compose v2、Bash 和 unzip;数据库使用 PostgreSQL 18.6,Redis 使用 8.10.1,Java 源码目标 17、Maven 3.9.12。下面的 Maven 镜像也可换成 maven:3.9.12-eclipse-temurin-17。
unzip distributed-state-lab.zip
cd distributed-state
docker compose --profile locks up -d --wait
docker compose ps
mkdir -p .m2
LAB_DIR="$(pwd -P)"
docker run --rm --network ds14-state-network \
--user "$(id -u):$(id -g)" -e HOME=/tmp -e MAVEN_CONFIG=/m2 \
--entrypoint mvn \
--mount "type=bind,src=$LAB_DIR,dst=/work" \
--mount "type=bind,src=$LAB_DIR/.m2,dst=/m2" \
--workdir /work maven:3.9.12-eclipse-temurin-25 \
-B -ntp -Dmaven.repo.local=/m2 -Duser.home=/tmp \
-Dtest=LeaseTest test预期 7 个测试、零失败、零错误、零跳过。测试分别检查:B 先写后 A 被拒、最大 fence 不自动获知 TTL、无条件写允许旧 A 覆盖、两个工作线程提交同一时段只产生一次业务效果、失败任务可重试,以及 Session 的迟到保存和绝对到期。
Maven 用宿主 UID/GID 写缓存与测试报告,不挂载 Docker socket。数据库连接使用普通 lab 角色,测试仅重建 lease_lab schema。Redis 使用 999:999、只读容器文件系统和临时数据目录,既不持久化也不暴露端口。包内演示密码不可用于共享环境;固定容器名需要在独立开发环境中使用。
三个服务 healthy 后,再运行 Redis 释放测试:
bash redis-release.sh脚本创建随机实验键,让 A 的 100ms 租约实际过期,随后由 B 重新获取。旧 A 比较删除返回 0,B 的 token 仍存在;B 自己释放返回 1,最后确认键不存在:
stale-release=0 current-owner-preserved=true owner-release=1如果服务连接失败,先看 docker compose logs --tail 40 redis a b 和健康状态。返回 unknown command DELEX 时核对实际 Redis 版本,旧版采用上面的 Lua;不能改成 DEL 来“兼容”。测试不会通过修改宿主时钟制造到期,也没有验证 Redis 主从切换。
多节点 Session 怎样保存和失效
本地、共享与自包含
| 方式 | 请求携带什么 | 多节点运行时的要求 |
|---|---|---|
| 节点本地 Session | 随机 Session ID | 粘性路由或复制;节点丢失会影响已有会话 |
| 共享 Session 存储 | Session ID | 所有节点访问同一可用存储,控制并发保存与到期 |
| 自包含令牌 | 已签名的声明 | 各节点验证签名和声明,按策略处理撤销与权限更新 |
粘性路由减少跨节点访问,但没有复制会话数据。共享 Session 避免依赖某一节点的内存,同时增加一次存储访问和故障依赖。Spring Session 提供 Redis、JDBC 等存储集成,具体支持与配置见 Spring Session。
Session ID 是高敏感凭据,应在登录后轮换,通过 Secure、HttpOnly 等 Cookie 属性限制暴露,并配合 CSRF 防护。身份验证、JWT 与撤销方式见 认证、Session 与 JWT;分布式存储不会替代这些安全规则。
空闲期限与绝对期限
空闲到期根据最后活动时间延长,绝对到期限制会话从创建开始最长能存活多久。刷新空闲时间时可以取 min(now + idleTimeout, absoluteDeadline),并先确认会话仍未到期、未撤销。
TTL 清理可以晚于逻辑失效,服务端读取时仍应检查有效条件。否则后台删除延迟会变成权限延长。自包含令牌没有中心查询时,退出登录常常只能让客户端丢弃令牌;是否要求即时撤销、可以容忍多长缓存窗口,要由安全契约决定。
idleRefreshDoesNotExtendAbsoluteSessionDeadline 把数据库记录的绝对期限设成过去,再执行带两种到期条件的刷新,检查更新行数为 0。它测试共享存储条件,未安装 Spring Session,也不冒充某个 Session 框架的自动行为。
并发保存不能把注销覆盖掉
请求 A 读到 Session 版本 0 后处理较慢,另一请求已经注销并将记录改成版本 1。A 结束时如果整对象无条件写回,会重新写入旧的登录状态。
UPDATE lease_lab.session
SET idle_until = LEAST(clock_timestamp() + interval '5 minutes', absolute_until),
version = version + 1
WHERE id = :id AND version = :observed_version
AND NOT revoked
AND idle_until > clock_timestamp()
AND absolute_until > clock_timestamp();更新失败后重新读取状态,已注销就拒绝恢复。示例用两条真实数据库连接重现先读、注销、迟到保存,检查旧版本更新零行。若采用字段级合并,认证状态、权限版本和过期字段仍需要明确冲突优先级,不能把“最后写入胜出”统一应用到所有属性。
注销与已经获准执行的业务请求还存在时间边界。要求敏感动作在执行时重新校验撤销状态,就要把该检查放在相应授权或业务提交位置;删除 Session 无法撤回已经提交的业务。
调度领取、业务提交与故障重放
一次触发怎样标识
多个节点执行同一个定时任务时,调度器可以通过共享存储领取触发记录,使正常情况下只有一个节点获得本次执行。但执行进程可能在业务提交后、调度状态更新前退出;恢复者必须判断原效果是否已完成。
任务身份应包含稳定 job ID 和计划触发时段,而不只是当前执行时间。例如 daily-summary + period-42 代表同一个业务结算区间;恢复重跑沿用该标识,新生成执行 ID 会绕过重复约束。
计划触发 → 领取任务 → 执行业务 → 提交结果 → 标记调度完成
↑
此处退出会触发恢复重放Quartz JDBC JobStore 集群通过共享数据库协调触发和故障恢复;实例 ID、相同调度数据源、时钟同步及恢复配置需要一起设置。Quartz 集群配置说明了触发领取与恢复行为。示例只验证业务提交层的重复保护,不包含 Quartz 部署。
结果与完成标记放在同一事务
示例为 (job, slot) 建立唯一键,插入执行记录和修改数据库计数器一起提交。两个工作线程提交同一个时段,唯一约束只允许一项插入生效;重复提交不会再次修改计数器。
BEGIN;
INSERT INTO lease_lab.executions(job,slot)
VALUES ('settle','SLOT-1') ON CONFLICT DO NOTHING;
-- 新插入才执行本次业务;重复时直接结束。
UPDATE lease_lab.counter SET value=value+1;
COMMIT;Java 方法检查插入行数后才执行 UPDATE。中途抛错时两项一起回滚,下一次还能重新执行;提交成功后重复执行不会增加计数器。LeaseTest 对并发成功、事务失败与重复恢复分别断言实际数据库结果。
如果任务向远端系统发送请求,需要远端幂等键或 Outbox。先把 job 状态改成 DONE 再发送,会漏动作;发送成功后再标记,则需要处理重复。完整机制见 Outbox 与 Inbox。
Misfire 与恢复速度
实例停机期间可能错过多个触发。恢复策略可以是跳过、合并成一次、逐次补跑,或只处理未结算的业务时间区间。选择取决于任务含义:刷新缓存通常可合并,逐期账单可能需要每期独立完成。
时区和夏令时也会改变某个本地时刻出现的次数。将计划时区、业务区间和实际开始时间分别保存,避免机器默认时区改变后重复计算。任务耗时超过周期时,还要明确允许并发、排队还是合并,不依赖线程池偶然排队形成规则。
恢复补跑使用受限并发,为正常流量保留数据库与网络容量。观察完成速率、待补时段和失败重试量;调度实例数增长不一定提高实际业务完成速度。
按现象处理,避免扩大重复窗口
| 现象 | 先查 | 下一步 |
|---|---|---|
| 锁长期等待 | owner、TTL、续期请求、服务端延迟 | 确认持有者与协议后处理,不盲删键 |
| 续期超时 | 最后确认的有效期、任务是否仍在运行 | 停止新受保护工作,依目标拒写机制处理在途请求 |
| B 的结果被旧 A 覆盖 | 目标是否检查 fence/版本,是否有旁路写入 | 补齐目标约束,核对已受影响业务 |
| 注销后又恢复登录 | 缓存、迟到 Session 保存、令牌撤销策略 | 修复版本条件,重放注销与并发请求 |
| 任务只在重启后重复 | 原业务提交与调度确认的位置 | 以原 job/slot 查询结果,再恢复同一操作 |
| 补跑拖慢在线请求 | 任务并发、连接池等待和净排空速率 | 限制补跑,按容量逐步恢复 |
修复锁服务不会自动修复已经重复执行的业务。先按操作 ID 对账,确认需要补偿的结果,再恢复正常流量。升级时保留旧任务记录和会话格式的兼容,回退代码也不能让已撤销会话或已完成时段重新生效。
实验完成后只回收本项目:
docker compose --profile locks downRedis 临时数据随容器销毁,数据库具名卷保留。确认不再需要其中的实验状态后,可执行 docker compose --profile locks down -v 删除这些卷;不要使用全局容器清理命令。镜像与 Maven 缓存可以留待下次复跑。
权威资料与规范地址
- Redis 分布式锁与比较删除:https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/
- etcd 锁与选举 API:https://etcd.io/docs/v3.6/dev-guide/api_concurrency_reference_v3/
- PostgreSQL 显式锁:https://www.postgresql.org/docs/18/explicit-locking.html
- PostgreSQL UPDATE:https://www.postgresql.org/docs/18/sql-update.html
- Spring Session:https://docs.spring.io/spring-session/reference/
- Quartz JDBC JobStore 集群:https://www.quartz-scheduler.org/documentation/quartz-2.3.0/configuration/ConfigJDBCJobStoreClustering.html
