14-分布式锁租约与业务约束
分布式锁到底保护了什么?租约、过期执行者与业务约束
进程拿到锁之后暂停很久,锁到期被其他进程获得,旧进程恢复后仍继续写数据。这个场景说明锁可以协调进入资格,却未必能阻止过期执行者产生副作用。
1. 等待时间、租约与续期分别控制什么
把锁获取与锁持有时间分开,再理解 watchdog。
等待时间与租约时间不要混淆
tryLock(waitTime,leaseTime,unit) 的等待时间控制最多等多久获得锁;租约时间控制获得后多久自动释放。等待 3 秒、租约 10 秒,不是整个业务必须在 3 秒内完成。
固定租约需要覆盖正常工作时间与合理余量,但任意增大也会延长持有者异常退出后的阻塞时间。业务耗时没有可靠上界时,固定租约不能天然保证互斥持续到完成。
watchdog 解决什么问题?
Redisson 在适用的未指定固定租约的锁调用中,通过 watchdog 延长锁有效期。客户端仍运行、续期正常时,可以避免普通长业务仅因初始过期时间而失锁。Redisson 锁文档
显式 leaseTime 与 watchdog 模式不能混为一谈。续期还依赖客户端调度与 Redis 通信;进程停止、网络故障或长时间停顿仍可能让锁过期。
2. 一个完整 Redisson 接入示例
示例保留依赖和所有权检查,不将正常路径扩展成永远互斥保证。
完整 Maven 与 Java 示例
以下使用 Java 17、Redisson 3.50.0,连接本地 Redis。pom.xml:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>demo</groupId><artifactId>lock-demo</artifactId><version>1.0.0</version>
<properties><maven.compiler.release>17</maven.compiler.release><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
<dependencies><dependency><groupId>org.redisson</groupId><artifactId>redisson</artifactId><version>3.50.0</version></dependency></dependencies>
<build><plugins>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.13.0</version></plugin>
<plugin><groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.5.0</version></plugin>
</plugins></build>
</project>
保存为 src/main/java/LockDemo.java:
import java.util.concurrent.TimeUnit;
import org.redisson.Redisson;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
public class LockDemo {
public static void main(String[] args) throws InterruptedException {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient client = Redisson.create(config);
try {
RLock lock = client.getLock("demo:lock:article:1");
if (!lock.tryLock(1, 2, TimeUnit.SECONDS)) {
System.out.println("busy");
return;
}
try {
Thread.sleep(3000);
System.out.println("still.owner=" + lock.isHeldByCurrentThread());
} finally {
if (lock.isHeldByCurrentThread()) lock.unlock();
}
} finally {
client.shutdown();
}
}
}
启动命令为 mvn compile exec:java -Dexec.mainClass=LockDemo。正常时间推进且没有外部干预时,预期业务休眠结束后已经超过固定租约,still.owner 为 false。这是待验证示例,不提供实测输出。
如果改用 tryLock(1,TimeUnit.SECONDS),不显式指定固定 leaseTime,将进入相应 watchdog 模式。是否符合所用版本与配置,应核对文档和源码。
检查所有权只能解决什么问题?
释放前判断当前线程持有,可以避免普通失锁后由旧持有者主动 unlock;RLock 也会校验对应所有者。但这不能阻止它在业务代码中继续发出数据库 UPDATE。
“检查锁还在,再写数据库”仍有时间窗口:检查后锁可能失效。最终存储必须具备自己的条件、版本或令牌检查。
3. 业务资源怎样拒绝迟到写入
fencing、版本和条件更新把约束放回资源侧。
如何拒绝过期执行者?
一种思路是 fencing token:每次有效锁授予得到单调递增令牌,下游只接受比已处理令牌更新的写入。旧持有者恢复后,旧令牌被拒绝。
但普通 RLock 不会自动让所有数据库写入具备这种能力。令牌的生成需要与锁授予协调,下游要原子保存并比较;Redis 状态回滚或故障切换也不能随意破坏令牌的单调性。
对于库存扣减,更直接的数据库条件可能是 UPDATE ... SET stock=stock-1 WHERE stock>0,结合事务、幂等键与业务规则。锁用于减轻竞争,数据约束负责最终正确性。
异常处理中常见的遗漏
获得失败不应继续执行临界业务。线程被中断应保留正确的中断处理,不能静默当作获得锁。unlock 放在 finally,但不要对未获得的锁执行释放。
锁名应该覆盖真正共享的资源;不同租户不必要地共用一个全局锁会降低吞吐,而同一业务使用不同锁名又失去协调效果。跨实例必须连接同一套相应锁服务。
4. 原子执行与锁不是同一种需求
限流的读改写可以通过适合的原子机制表达,不必所有问题都加锁。
算法对应的流量语义
固定窗口在一个窗口内累计请求,实现简单,但边界附近可能出现较大突发。滑动窗口更精细,需要额外状态或近似计算。
令牌桶按速率补充令牌,最多保存 capacity 个,允许容量范围内的突发;长期平均速率与瞬间突发不是同一个限制。
限流不是并发隔离。每秒只接收十个请求,但每个请求运行一分钟,仍可能形成较高在途并发。
固定长度窗口脚本
保存为 fixed-window.lua:
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
if #KEYS ~= 1 or #ARGV ~= 2
or not limit or limit < 1 or limit > 1000000000 or limit ~= math.floor(limit)
or not window or window < 1 or window > 86400000 or window ~= math.floor(window) then
return redis.error_reply('positive integers required')
end
local count = redis.call('INCR', KEYS[1])
if count == 1 then redis.call('PEXPIRE', KEYS[1], window) end
local allowed = 0
if count <= limit then allowed = 1 end
return {allowed, count, redis.call('PTTL', KEYS[1])}
调用:
redis-cli --eval fixed-window.lua rate:{user7}:article , 3 10000
在同一个尚未到期窗口里,前三次预期 allowed 为 1,之后为 0;第三个返回值是剩余毫秒。本例从首次请求开始一个固定长度窗口,不按自然分钟对齐。拒绝请求也增加计数,但不会自动延长 TTL。
如果用“先 INCR,再由客户端 EXPIRE”的两个独立命令,客户端中断可能留下无过期计数。脚本把这组操作放在一个执行单元中。
原子执行不等于回滚事务
Lua 执行期间阻止其他普通命令交错,但脚本出现运行错误时,不应假设此前所有写入会自动撤销。输入校验应尽量在首次写入前完成,Key 类型也应统一。
脚本不能长时间循环或访问任意大集合,否则会阻塞其他请求。Redis Cluster 的多 Key 脚本还要求相关 Key 满足槽位约束,hash tag 可帮助组织。Lua 文档
5. 设计判断与适用边界
先区分协调工作与保护业务不变量。Redis 锁减少多个实例同时开展相同工作,数据库唯一约束和版本条件决定结果是否允许提交。只有锁而没有资源侧规则,租约失效后仍可能出现重复或覆盖。
重试前确认业务结果,释放前检查当前所有权,异常处理中保留中断状态。续期也依赖调度和通信,不把 watchdog 当成业务永远完成的承诺。涉及支付、工单等副作用时,仍需要稳定操作标识与执行记录。
租约模式强调时限与续约。应用仍需考虑过期持有者,以及资源侧怎样拒绝已经失效的操作。 Unmesh Joshi:Lease。
6. 问答
问:watchdog 保证锁永不丢失吗?
不保证。它依赖客户端调度、通信与 Redis 状态。
问:leaseTime 设得很大是否就正确了?
只能改变时间窗口,也会延长异常退出后的等待,不能覆盖所有故障。
问:限制 QPS 就能限制在途并发吗?
不能,耗时长会积累并发,需要额外隔离或并发限制。
问:令牌桶允许突发,是否等于没有限流?
不是,容量控制突发,补充速率控制长期供给。