分布式锁
这篇解决什么
多实例同时跑同一段写逻辑时,只让一个线程进临界区。读完能改 Redisson RLock、会在方法上加 @Lock4j,并分清租约时间和解锁时机。
默认连本机 Redis 127.0.0.1:6379,库号 0。管理端前缀仍是 /admin-api。锁和缓存共用这套连接,不必再开一套 Redis。
示意图:Service 调 DAO 或 @Lock4j;框架用 Redisson 在 Redis 上加锁。
组件位置
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| Protection Starter | 分布式锁、幂等、限流 | ruoyi-office/yudao-framework/yudao-spring-boot-starter-protection/ |
| Redis Starter | 提供 RedissonClient,锁不再单独配连接 | ruoyi-office/yudao-framework/yudao-spring-boot-starter-redis/ |
| Lock4j | lock4j-redisson-spring-boot-starter,BOM 2.2.7 | yudao-dependencies/pom.xml |
| 失败策略 | 抢锁失败抛 ServiceException(423) | .../lock4j/core/DefaultLockFailureStrategy.java |
| 本地超时 | acquire-timeout / expire | ruoyi-office/yudao-server/src/main/resources/application-local.yaml |
| 单体入口 | 引入 protection Starter | ruoyi-office/yudao-server/pom.xml |
编程式锁直接注入 RedissonClient。声明式锁用 @Lock4j,类路径上要有 com.baomidou.lock.annotation.Lock4j;protection 的 pom 把它标成 optional,编译找不到注解时在该模块补同一坐标。
编程式锁
application-local.yaml 只配 Redis 地址。Redisson 跟 Spring Data Redis 共用这三项,锁这边没有第二套 host。
spring:
data:
redis:
host: 127.0.0.1
port: 6379
database: 0redissonClient.getLock(key) 得到可重入锁,Value 是 Hash。需要红锁、读写锁时换 getRedLock / getReadWriteLock,支付和结账用的都是普通 RLock。
支付通知
每个通知任务同一时刻只跑一条。Key 和租约写在支付模块,不写死在 Service 里。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
pay_notify:lock:%d | 按任务 id 一把锁 | yudao-module-pay-server/.../dal/redis/RedisKeyConstants.java |
PayNotifyLockRedisDAO | lock(id, timeout, runnable) | .../dal/redis/notify/PayNotifyLockRedisDAO.java |
PayNotifyServiceImpl | 加锁后再比对 notifyTimes | .../service/notify/PayNotifyServiceImpl.java |
| 租约 | NOTIFY_TIMEOUT_MILLIS,120 秒 | 同上 |
public void lock(Long id, Long timeoutMillis, Runnable runnable) {
String lockKey = formatKey(id);
RLock lock = redissonClient.getLock(lockKey);
try {
lock.lock(timeoutMillis, TimeUnit.MILLISECONDS);
runnable.run();
} finally {
lock.unlock();
}
}lock(leaseTime, unit) 会一直等到拿到锁,租约是 120 秒,到期不续期。业务比 120 秒长,别的线程会抢到同一把锁。
public void executeNotify(PayNotifyTaskDO task) {
notifyLockCoreRedisDAO.lock(task.getId(), NOTIFY_TIMEOUT_MILLIS, () -> {
PayNotifyTaskDO dbTask = notifyTaskMapper.selectById(task.getId());
if (ObjectUtil.notEqual(task.getNotifyTimes(), dbTask.getNotifyTimes())) {
return;
}
getSelf().executeNotify0(dbTask);
});
}两个调度同时满足通知条件时,后拿到锁的那个仍可能进来。所以锁内再读库,notifyTimes 对不上就放弃。锁只保证互斥,不保证「只通知一次」。
示意图:先加锁,再按次数判断;finally 里解锁。
钱包与结账
同一套 RLock,租约和等待策略按业务选。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
pay_wallet:lock:%d | 创建按 userId,改余额按 walletId | PayWalletLockRedisDAO |
| 钱包租约 | UPDATE_TIMEOUT_MILLIS,120 秒 | PayWalletServiceImpl |
finance:close:{} | 按账套 bookId | FinanceCloseServiceImpl |
| 结账 | tryLock(0, 5, MINUTES),抢不到抛 CLOSE_LOCK_FAIL | 同上 |
| Stream 清理 | redis:stream:message-cleanup:lock | RedisStreamMessageCleanupJob |
| IoT 清理 | …:lock:iot,不能和业务清理抢同一把 | 同上 |
| 消息补发 | tryLock(),没抢到就跳过本轮 | RedisPendingMessageResendJob |
钱包创建是双重检查:外面先查一次,没有再锁内查一次再 insert。结账 tryLock 的等待是 0,避免第二个操作员堵在锁上。
定时 Job 用 tryLock() 无参:拿不到就记 debug 并返回。解锁前看 isHeldByCurrentThread(),避免租约过期后误解别人的锁。
编程式还是 @Lock4j
要自己选 lock / tryLock、红锁、读写锁:用 RedissonClient。 按方法拦截、失败直接回 423:用 @Lock4j。 拿到锁还要再读库判断状态:支付通知这种写法更合适。
热点缓存回源用的短 TTL SETNX(例如天气)不是可重入业务锁,见 Redis 与本地缓存。
声明式锁
YudaoLock4jConfiguration 写在 LockAutoConfiguration 前面,注册 DefaultLockFailureStrategy。只有类路径存在 @Lock4j 时才生效。
lock4j:
acquire-timeout: 3000
expire: 30000单位是毫秒:最多等 3 秒抢锁;拿到后租约 30 秒。方法上的 expire / acquireTimeout 覆盖这两项。
| 名称 | 说明 | 仓库路径 |
|---|---|---|
@Lock4j | 方法进出自动加解锁,keys 支持 Spring EL | Lock4j |
lock4j:%s | 声明式锁的 Key 形态,方便在 Redis 里按前缀找 | Lock4jRedisKeyConstants.LOCK4J |
| 失败 | GlobalErrorCodeConstants.LOCKED,code 423 | yudao-common/.../GlobalErrorCodeConstants.java |
| 文案 | 「请求失败,请稍后重试」 | 同上 |
@Lock4j
public void simple() {
// 用 yaml 里的 acquire-timeout / expire
}
@Lock4j(keys = {"#userId"}, expire = 60000, acquireTimeout = 1000)
public void byUser(Long userId) {
// Key 带 userId;租约 60 秒,最多等 1 秒
}抢不到锁时 DefaultLockFailureStrategy.onLockFailure 打 debug,再抛 ServiceException。前端按 code !== 0 提示即可,不要再包一层业务错误码。
防重复提交用 @Idempotent:成功后 Key 还在,一段时间内同参不能再进。成功就删 Key 变成互斥,那种场景用 @Lock4j,不要把幂等当锁使。
持有时间与解锁
租约到期不会替你续命
RLock.lock(leaseTime, unit) 和 Lock4j 的 expire 都是租约。临界区比租约长,锁会先掉,别的线程会进来。 支付通知、钱包按 120 秒估;结账按 5 分钟。估不准就加长,或改用不传 lease 的 lock() 走看门狗续期。
finally 里先确认是自己的锁
unlock 必须进 finally。租约已过或 tryLock 失败时,Redisson 再 unlock 会报错。 FinanceCloseServiceImpl、RedisStreamMessageCleanupJob 先判断 isHeldByCurrentThread()。 PayNotifyLockRedisDAO 的 lock() 会阻塞到拿到锁,再在 finally 里直接 unlock。
本机连不上 127.0.0.1:6379 时,先看 Redis 进程和 application-local.yaml,不要改成别的环境的地址。
配置与操作
互斥方法加 @Lock4j,Key 用 SpEL。本机必须能连 127.0.0.1:6379。解锁放 finally。
开启见 框架层。
