[037][缓存模块]基于 Guava Striped 的声明式本地锁设计与实现

作者:袖梨 2026-08-26

[037][缓存模块]基于 Guava Striped 的声明式本地锁设计与实现并不只看表面做法,关键还要理解相关条件、限制和后续影响。

[037][缓存模块]基于 Guava Striped 的声明式本地锁设计与实现

本文章代码: gitee , gitcode , github

[037][缓存模块]基于 Guava Striped 的声明式本地锁设计与实现

1. 概述

在单机应用开发中,为了避免并发操作导致的数据不一致或资源竞争,本地锁是最常用的同步手段之一。Java 提供了 synchronizedReentrantLock 等基础锁机制,但它们缺乏业务语义,锁粒度往往过粗(如整个方法或对象),且锁 key 无法灵活地与业务参数关联。

本文介绍一套轻量级的声明式本地锁解决方案,基于 Guava Striped 实现细粒度锁池,结合 Spring AOP 与 SpEL 表达式,允许开发者通过注解优雅地完成本地锁控制。该方案已在生产环境中稳定运行,适用于对低延迟、无外部依赖(如 Redis)有要求的单机并发控制场景。

2. 设计思想

该方案的核心目标:

声明式:通过注解 @LocalLockable 标记需要同步的方法,降低锁代码的侵入性。 细粒度:锁的 key 由业务参数(SpEL 表达式)动态生成,不同 key 之间互不影响。 可控超时:支持锁等待时间配置,避免死锁或长时间阻塞。 低内存:使用 Striped.lazyWeakLock(1024) 创建固定数量的锁实例,通过哈希映射到不同 key,兼顾细粒度与内存效率。

整体架构由三个组件协作完成:

组件职责
@LocalLockable注解,定义锁的前缀、key 表达式、等待时间及单位
LocalLockableAspectAOP 切面,解析 SpEL,调用锁服务
LocalLockService底层锁服务,封装 Guava Striped 的获取与释放逻辑

3. 核心实现解析

3.1 动态锁 key:SpEL 表达式支持

锁的粒度取决于 key 属性的 SpEL 表达式。例如 #userId 表示使用方法参数 userId 的值作为锁标识。切面通过 SpelMethodBasedExpressionEvaluator(一个自定义的 SpEL 工具类)解析表达式:

String value = spelMethodBasedExpressionEvaluator.getValue(method, args, localLockable.key(), String.class);String key = localLockable.prefix()   value;

这种方式使得锁 key 可以依赖任意方法参数、嵌套属性甚至调用 Bean 方法,灵活性极高。prefix 属性可用于添加业务前缀(如 "user:"),方便区分不同模块的锁。

3.2 锁服务实现:Guava Striped 的精巧应用

LocalLockService 内部维护了一个 Striped<Lock>

private final Striped<Lock> stripedLock = Striped.lazyWeakLock(1024);

Striped 是 Guava 提供的锁池工具,它根据传入 key 的哈希值,从固定数量的锁中选取一个返回。lazyWeakLock(1024) 会创建 1024 个 ReentrantLock(弱引用缓存),当锁不再被引用时可被 GC 回收。相比 ConcurrentHashMap 动态创建锁的方式,Striped 在并发量高时内存占用更稳定。

lock(String lockKey) 方法直接调用 stripedLock.get(lockKey) 获得对应的锁。由于底层锁数量有限(1024),理论上不同 key 可能共享同一个物理锁实例(哈希碰撞),但概率较低且对业务影响很小。如果应用要求绝对隔离,可增大 stripes 数量(如 4096)。

3.3 两种加锁语义:超时与非超时

LocalLockService 提供了四组重载方法,分别支持有/无返回值、有/无超时场景:

无超时:直接调用 lock.lock(),线程会阻塞直到获取锁。 有超时:调用 lock.tryLock(timeout, unit),若超时未获取则抛出 LockCreateException

这种设计让调用方可以根据业务容忍度选择合适的行为。例如,用户注册接口应快速失败,而非长时间等待。

切面根据 waitTime > 0 自动选择相应的重载。特别注意的是,对于带超时的 doInLock,切面传递的是 ThrowingCallable<T>,它可以抛出受检异常(如 joinPoint.proceed() 抛出的 Throwable),而 Supplier<T> 则要求将异常包装为 RuntimeException

3.4 中断处理与异常封装

tryLock 过程中,若当前线程被中断(InterruptedException),代码会恢复中断状态并抛出 LockException

catch (InterruptedException e) { Thread.currentThread().interrupt();throw new LockException(lockKey, e);}

这样的处理符合 Java 并发编程的最佳实践:保留中断标志,同时让上层感知锁获取失败。

4. 使用示例

假设有一个用户积分更新方法,需要按用户 ID 互斥执行:

@Servicepublic class UserPointService { @LocalLockable(prefix = "user:point:", key = "#userId", waitTime = 500)public void updatePoint(Long userId, Integer delta) { // 业务逻辑:查询积分、计算、保存}}

如果同一个 userId 的请求在 500ms 内未能获得锁,将抛出 LockCreateException,调用方可以捕获并返回“请稍后重试”的响应。

对于无需超时的场景(如数据初始化任务),可以设置 waitTime = 0,使用阻塞锁:

@LocalLockable(prefix = "task:", key = "'dataSync'", waitTime = 0)public void syncData() { // 长时间运行的数据同步}

5. 注意事项与最佳实践

5.1 适用范围:单机部署

该方案基于 JVM 内的 Striped 锁,无法跨进程工作。在分布式或多实例部署下,应改用分布式锁(如 Redis、ZooKeeper)。因此,本锁仅适用于保证单机内的一致性,或作为分布式锁的本地前置过滤(减少远程锁调用)。

5.2 SpEL 表达式必须保证非空且稳定

key 表达式求值结果将直接作为锁 key 的后半部分。如果表达式返回 null,拼接后的 key 可能为 "prefixnull",导致不同业务意外共享同一锁。建议在表达式中使用 #userId?.toString() ?: 'default' 等形式防御。

5.3 等待时间的选择

对于短操作(如内存计算、单条 SQL),waitTime 可设为 100~500ms。 对于涉及远程调用或复杂事务的操作,等待时间应大于预估的最大执行时间,否则容易触发超时异常。 若业务允许重试,可以在调用方实现重试逻辑。

5.4 避免锁嵌套死锁

Striped 底层使用的是 ReentrantLock,可重入。但不同锁 key 之间的嵌套调用仍可能产生死锁(例如线程 A 持有 key1 等待 key2,线程 B 持有 key2 等待 key1)。建议在涉及多个锁时统一申请顺序,或使用超时锁作为兜底。

5.5 性能考量

1024 个锁实例足以支撑绝大多数场景。若应用有极高频的锁竞争(每秒数万次),可适当增加 stripes 数量(如 Striped.lock(4096)),以降低哈希碰撞概率。需注意,stripes 数量越大,内存开销略增,但远低于为每个 key 创建独立锁。

6. 与同类方案对比

方案优点缺点
synchronized(this)简单粒度粗,无法按参数隔离
ConcurrentHashMap 动态锁完全细粒度每个 key 创建锁对象,内存膨胀,需手动清理
Guava Striped(本方案)内存可控,锁池复用,声明式存在极小哈希碰撞风险,仅限单机
Redisson 分布式锁跨进程,功能丰富网络开销,依赖 Redis

7. 总结

本文介绍的基于 Guava Striped 的声明式本地锁,通过注解与 AOP 实现了业务参数级别的细粒度锁控制,具有以下特性:

简洁:一行注解替代手写 lock / unlock 模板代码。 灵活:SpEL 支持动态构建锁 key。 可靠:超时机制与中断处理防止死锁。 高效:Striped 锁池在内存与并发性能间取得平衡。

对于不需要分布式协调的单机应用,这是一个轻量而强大的并发控制工具。其设计模式(注解 切面 锁服务)也可以轻松扩展至分布式锁,例如替换 LocalLockService 为基于 Redis 的实现,而注解层完全不变。

希望本文能帮助读者理解并合理应用声明式本地锁,提升代码的可维护性与并发安全性。

相关文章

精彩推荐