Redis——分布式锁注解

本文最后更新于:17 天前

引言

在分布式 Java 应用中,锁是保证并发安全的常见手段。Redisson 基于 Redis,可提供分布式、可重入、阻塞式的 RLock。为了避免在业务层重复编写锁获取/释放逻辑,本文通过 自定义注解 + Spring AOP 封装一套轻量级分布式锁方案,既保持了 Redisson 的强大特性,又让业务代码足够「clean」。

方案设计目标

目标 说明
零侵入 业务侧仅需加一个注解即可完成加解锁
灵活拼锁 支持 SpEL 表达式,自由组合多维度键(如用户 ID、业务单号)
超时可控 兼容显式 leaseTime 与 Redisson 自带 watch-dog 机制
异常兜底 获取锁失败、业务异常、释放锁失败均有友好的提示与日志

注解

代码

创建注解 RLock 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RLock {

/**
* 锁的前缀
*/
String prefix() default "";

/**
* 锁的名称,使用SpEL表达式(若非#开头,则直接使用该字符串作为锁名称)
*/
String expr() default "";

/**
* 获取锁等待时间
*/
long waitTime() default 0L;

/**
* 锁超时释放时间,为0则启用watchdog机制
*/
long leaseTime() default 30 * 1000L;

/**
* 锁的时间单位
*/
TimeUnit unit() default TimeUnit.MILLISECONDS;

/**
* 错误提示信息
*/
String errorMessage() default "当前操作正在进行中,请稍后再试";

}

关键点

  1. prefix
    • 用于区分不同业务域,建议与业务缩写保持一致,如 ORDERPAY
  2. expr
    • 为空:使用「方法全限定名 + 用户 ID」默认拼锁。
    • #开头:按照 SpEL 解析,动态读取方法入参。
    • 其他:视为普通字符串。
  3. waitTime / leaseTime
    • waitTime 决定阻塞时间;到达上限仍未抢到锁,则抛异常。
    • leaseTime > 0:固定持锁时长; = 0:开启 watch-dog,Redisson 默认每 10 s 自动续期,直到线程正常释放锁或宕机超时。

切面定义

流程代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class RLockAspect {

private static final String LOCK_KEY_PREFIX = "REDISSON_LOCK";

private final RedissonClient redissonClient;
private final SpelExpressionParser parser = new SpelExpressionParser();
private final ParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer();

@Pointcut("@annotation(me.zhengjie.annotation.RLock)")
public void pointcut() {
}

@Around("pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
// 签名及注解
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method signatureMethod = signature.getMethod();
RLock annotation = signatureMethod.getAnnotation(RLock.class);

// 获取锁名称
String lockKey = getKey(joinPoint);

// 上锁
org.redisson.api.RLock lock = redissonClient.getLock(lockKey);
boolean isLocked = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), annotation.unit());
if (!isLocked) {
throw new BadRequestException(annotation.errorMessage());
}

// 执行方法
try {
return joinPoint.proceed();
} catch (Throwable throwable) {
log.error("执行方法时发生异常: {}", throwable.getMessage(), throwable);
throw throwable;
} finally {
// 释放锁
try {
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
} catch (Exception e) {
log.error("释放锁 {} 时发生异常: {}", lockKey, e.getMessage(), e);
}
}
}

private String getKey(ProceedingJoinPoint joinPoint) {
// 见下文
}

}

运行机制

步骤 细节 风险点 & 处理
解析注解 通过 MethodSignature 拿到 RLock 实例 ——
拼接锁名 getKey() 将全局前缀、业务前缀、表达式结果合并成
LOCK_KEY_PREFIX:prefix:expr
解析失败即抛业务异常,避免锁名为 null
尝试加锁 tryLock(wait, lease, unit)
- 返回 true:进入临界区
- 返回 false:直接抛自定义提示
防止「假死」:未获取锁立即失败
执行业务 joinPoint.proceed() 业务异常无论何时抛出皆进入 finally
释放锁 仅当前线程且锁仍持有时调用 unlock() 避免误解锁他人持有的锁;
释放失败打印 error 日志

获取锁名称

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
private String getKey(ProceedingJoinPoint joinPoint) {
// 签名及注解
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method signatureMethod = signature.getMethod();
RLock annotation = signatureMethod.getAnnotation(RLock.class);

// 获取锁名称表达式
String expr = annotation.expr();
String prefix = StrUtil.blankToDefault(annotation.prefix(), "DEFAULT");
// 如果表达式为空,则获取 全限定方法名 + 用户ID 作为锁名称
if (StrUtil.isBlank(expr)) {
String userId = null;
try {
userId = SecurityUtils.getCurrentUserId().toString();
} catch (Exception e) {
log.warn("获取用户ID失败,使用默认值: {}", e.getMessage());
}
return StrUtil.format("{}:{}:{}:{}",
LOCK_KEY_PREFIX,
prefix,
signatureMethod.getDeclaringClass().getName() + "." + signatureMethod.getName(),
userId);
}
// EL表达式
if (expr.startsWith("#")) {
try {
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
EvaluationContext ctx = new MethodBasedEvaluationContext(null, method, joinPoint.getArgs(), nameDiscoverer);
Object val = parser.parseExpression(expr).getValue(ctx);
if (val == null) {
throw new BadRequestException("锁名称表达式计算结果为null,请检查表达式: " + expr);
}
return StrUtil.format("{}:{}:{}", LOCK_KEY_PREFIX, prefix, String.valueOf(val));
} catch (Exception e) {
log.error("解析锁名称表达式失败: {}", expr, e);
throw new BadRequestException("锁名称表达式解析失败,请检查表达式: " + expr);
}
}
// 普通字符串直接作为锁名称
else {
return StrUtil.format("{}:{}:{}", LOCK_KEY_PREFIX, prefix, expr);
}
}

关键思路

  • 多维度唯一性LOCK_KEY_PREFIXprefix 避免跨业务冲突;表达式或方法签名确保同业务不同场景隔离。
  • 弹性 Fallback:开发者若忘记写 expr,亦能拿到一个规则化且唯一的锁键。
  • SpEL 灵活取值:支持链式取参(#param.sub.id),适配复杂入参结构。

实践

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@RestController
@RequestMapping("/test")
public class TestController {

/**
* 固定名称锁测试
*/
@RequestMapping("/1")
@RLock(prefix = "test", expr = "11")
public void test1() {
System.out.println("Hello, World!");
}

/**
* 路径变量锁测试
*/
@GetMapping("/2/{id}")
@RLock(prefix = "test", expr = "#id")
public void test2(@PathVariable("id") long id) {
System.out.println("Hello, World!");
}

/**
* 请求体参数锁测试
*/
@PostMapping("/3/{pathVar}")
@RLock(prefix = "test", expr = "#param.param2.id")
public void test3(@PathVariable("pathVar") String pathVar, @RequestBody TestParam param) {
System.out.println("Hello, World!");
}

/**
* 全局锁(方法 + 用户)测试
*/
@GetMapping("/4")
@RLock(prefix = "test4")
public void test4() {
System.out.println("Hello, World!");
}

@Setter
@Getter
public static class TestParam {
private long id;
private TestParam2 param2;

}

@Setter
@Getter
public static class TestParam2 {
private long id;
private String name;
}

}

实战建议

  1. 锁范围越小越好
    • 只对真正存在并发风险的代码块加锁,避免长事务。
  2. waitTime 合理设置
    • UI 请求链建议 ≤ 1 s,定时任务可适当放宽。
  3. leaseTime 建议为 0
    • 交由 Redisson watch-dog 自动续约,不易因单机 GC 暂停误释放。
  4. 锁粒度设计
    • 订单、库存等按「业务主键」锁;
    • 全量任务或定时脚本可用固定字符串,如 "GLOBAL"

总结

通过 @RLock + RLockAspect,我们将分布式锁逻辑彻底下沉,业务侧只需关注「业务前缀 + 唯一键」即可安全并发。与原生 Redisson 相比,此封装:

  • 去除了样板代码,降低认知负担;
  • 保持了 Redisson 高可用、高性能的优势;
  • 通过 SpEL 提供了高度自定义的锁粒度;
  • 兼容 watch-dog 机制,稳定性更佳。

一句话:让分布式锁像 @Transactional 一样自然。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!