本文将介绍一种锁的封装方式,用于简化项目中的加锁操作。特点是同时支持 JVM 锁和分布式锁,可以根据需要自由选择。我们的口号是:让项目中没有难用的锁~
使用效果
针对不同使用场景,提供了声明式风格和编程式风格两种操作方式。
声明式风格使用注解使用,使用简单,直接在方法上添加注解 @WithLock
即可。
1 2 3 4 5 6
| @WithLock(key = "'test:' + #key", waitTime = 200, leaseTime = 500, fair = true) public String testMethod(String key) { return "success"; }
|
因为基于 Spring AOP,所以也会受到 Spring AOP 的限制,比如只能在 Spring Bean 中使用,不能在私有方法中使用,不能用在 final 方法, 不能在静态方法使用,不能同类方法调用等。
编程式风格使用模板方法,需要先获取 LockTemplate
实例,然后调用其 execute
方法。
1 2 3 4 5 6 7 8 9 10 11
| LockInfo lockInfo = LockInfo.builder() .key("test:" + key) .waitTime(200) .leaseTime(500) .timeUnit(TimeUnit.MILLISECONDS) .fair(true) .build(); lockTemplate.execute(lockInfo, () -> { return "success"; });
|
不难看出,设计上借鉴了 Spring 事务的思路,通过声明式风格提供简便的粗粒度的锁控制,通过编程式风格提供复杂的细粒度的加锁操作。
作为题外话,Spring 充满了这种设计,声明式风格和编程式风格结合,比如 Spring Data 系列中,各种 Repository
接口代表了声明式风格,各种 Template
代表了编程式风格,通常将编程式作为声明式的补充。
另一个功能是可配置的锁实现方式,默认使用本地锁,同时支持 Redis 分布式锁,可以根据需求自由切换。
配置方式
基于 Spring Boot 的自动装配机制,通过 @EnableLocking
注解开启锁支持。同时,提供了 LockProperties
配置类,用于配置锁的默认参数。
1 2 3 4 5 6 7 8 9
| app: lock: enabled: true type: local wait-time: 200 lease-time: 0 default-fair: true key-store-prefix: ‘lock:‘
|
实现原理
让我们先看看代码结构。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| ├── annotation │ ├── EnableLocking.java # 开启锁支持 │ └── WithLock.java # 方法级锁注解 ├── aspect │ └── LockAspect.java # AOP 切面,用于支持 @WithLock 注解 ├── config │ ├── LockAutoConfiguration.java # 自动装配类 │ ├── LockingEnabledCondition.java # 启用配置类,用于支持 @EnableLocking 注解 │ └── LockProperties.java # 配置类 ├── core │ ├── LocalLockExecutor.java # JVM 锁实现类 │ ├── LockExecutor.java # 加锁操作接口 │ ├── LockInfo.java │ └── RedisLockExecutor.java # Redis 锁实现类,使用 Redisson 实现 ├── exception │ └── LockException.java ├── template │ └── LockTemplate.java # 编程式模板 └── util └── SpELUtil.java # SpEL 表达式工具类
|
下面是核心组件。
LockExecutor
接口,实现加锁功能,也是多种所实现的关键
LockTemplate
定义了加锁命令的范式,内部调用 LockExecutor
实现加锁
LockAspect
Spring AOP 切面,用于支持 @WithLock
注解,内部调用 LockTemplate
实现加锁功能
LockExecutor 接口比较简单。
1 2 3 4
| public interface LockExecutor { boolean tryLock(LockInfo lockInfo); void unlock(LockInfo lockInfo); }
|
首先提供本地锁的实现。使用 juc 包中的 ReentrantLock 类,低并发性能比 synchronized 差,但编程更灵活。由于要关联 key 和锁,使用 ConcurrentHashMap 缓存锁对象。
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
| public class LocalLockExecutor implements LockExecutor { private final ConcurrentHashMap<String, LockHolder> lockMap = new ConcurrentHashMap<>();
@Override public boolean tryLock(LockInfo lockInfo) {
LockHolder holder = lockMap.compute(lockInfo.getKey(), (key, existingHolder) -> { if (existingHolder != null) { existingHolder.updateLastAccessTime(); return existingHolder; } return new LockHolder(new ReentrantLock(lockInfo.isFair()), lockInfo.isFair()); });
try { return holder.getLock().tryLock(lockInfo.getWaitTime(), lockInfo.getTimeUnit()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } }
@Override public void unlock(LockInfo lockInfo) {
lockMap.computeIfPresent(lockInfo.getKey(), (key, holder) -> { if (holder.getLock().isHeldByCurrentThread()) { holder.getLock().unlock(); if (!holder.getLock().hasQueuedThreads() && holder.isExpired()) { return null; } } return holder; }); } }
|
LockHolder 类的实现。
1 2 3 4 5
| class LockHolder { private final ReentrantLock lock; private final long lastAccessTime; private final boolean fair; }
|
lastAccessTime
用于实现锁对象的超时清理,避免某个线程长时间占用锁对象,或是忘了释放锁的情况。同时因为 unlock 时并非立即从 Map 移除 holder 对象,取决于当时是否有其他线程在排队,通过自动清理机制作为兜底,可以避免内存泄露。
关键代码如下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| private final ScheduledExecutorService scheduler;
private void startCleanupTask() { scheduler.scheduleAtFixedRate(() -> { try { cleanup(); } catch (Exception e) { log.error("Error during lock cleanup", e); } }, CLEANUP_INTERVAL, CLEANUP_INTERVAL, TimeUnit.MILLISECONDS); }
private void cleanup() { lockMap.entrySet().removeIf(entry -> { LockHolder holder = entry.getValue(); return !holder.getLock().isLocked() && !holder.getLock().hasQueuedThreads() && holder.isExpired(); }); }
|
接下来是 Redis 分布式锁的实现。直接使用 Redisson 提供的分布式锁,避免重复造轮子。
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
| public class RedisLockExecutor implements LockExecutor { private final RedissonClient redissonClient; private final String lockPrefix;
@Override public boolean tryLock(LockInfo lockInfo) {
RLock lock = getLock(lockInfo); try { return lock.tryLock( lockInfo.getWaitTime(), lockInfo.getLeaseTime(), lockInfo.getTimeUnit() ); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } }
@Override public void unlock(LockInfo lockInfo) {
RLock lock = getLock(lockInfo); if (lock.isHeldByCurrentThread()) { lock.unlock(); } }
private RLock getLock(LockInfo lockInfo) { String key = lockPrefix + lockInfo.getKey(); return lockInfo.isFair() ? redissonClient.getFairLock(key) : redissonClient.getLock(key); } }
|
Redis 自带超时机制,不需要手动清理。
公平性的细节
这里涉及到一个细节,是否需要保证同一个 Key 前后加锁的一致公平。简单来说,先用 key-1 申请公平锁加锁成功,锁还没释放时,又用 key-1 申请非公平锁。也可以先申请非公平锁,再申请公平锁,顺序不重要。这里的问题是第二次加锁是否成功。这个问题又包含两种情况。
- 前后两次加锁的线程相同,此时涉及到了锁的可重入性质
- 前后两次加锁的线程不同,此时取决于锁的互斥性
对于 LocalLockExecutor 而言,第二次会从缓存中获取到第一次加锁时创建的锁对象,表现和先后申请同一把锁一致,只有在相同线程第二次加锁才能成功,否则会等待。这很明显有问题,锁的公平性变了,第二次需要的是非公平锁,拿到的却是公平锁。
对于 RedisLockExecutor 而言,内部使用 Redisson 实现。这不存在获取到不同公平性锁的情况,但 Redisson 的公平锁和非公平锁使用的 key 相同,导致二者会互斥,也会共用重入性。具体表现为如果前后两次加锁线程相同,则第二次加锁会成功,否则等待。
两个 LockExecutor 实现类的表现一致,但并非意味着没问题,而是错到一起了。理想情况是,重入时也要比较锁的公平性,只有前后公平性相同时才允许重入。
针对这个要求,对代码进行调整。
首先为 LocalLockExecutor 添加公平性检查。在 LockHolder 中添加 fair
属性,在 tryLock 时检查,如果前后两次加锁的公平性不同,则加锁失败返回 false。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| @Override public boolean tryLock(LockInfo lockInfo, boolean strictFair) { LockHolder holder = lockMap.compute(lockInfo.getKey(), (key, existingHolder) -> { if (existingHolder != null) { existingHolder.updateLastAccessTime(); return existingHolder; } return new LockHolder(new ReentrantLock(lockInfo.isFair()), lockInfo.isFair()); }); if (strictFair && holder.isFair() != lockInfo.isFair()) { log.warn("Lock fairness setting mismatch for key: {}. Existing: {}, Requested: {}", lockInfo.getKey(), holder.isFair(), lockInfo.isFair()); return false; }
try { return holder.getLock().tryLock(lockInfo.getWaitTime(), lockInfo.getTimeUnit()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } }
|
这里用 strictFair 参数控制是否严格检查公平性,提供一个灵活的选择。当 strictFair 为 false 时,不检查公平性,退化到最开始的表现,是否加锁成功取决于是否同一线程加锁。
对于 RedisLockExecutor,由于只需要解决线程重入的公平性问题,所以不需要考虑分布式的情况,直接用 ConcurrentHashMap 记录锁的公平性,再基于记录在加锁时检查。
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
| private final ConcurrentHashMap<String, Boolean> fairnessCache = new ConcurrentHashMap<>(); @Override public boolean tryLock(LockInfo lockInfo, boolean strictFair) {
if (hasFairnessMismatch(lockInfo, strictFair)) return false;
RLock lock = getLock(lockInfo); try { boolean acquired = lock.tryLock( lockInfo.getWaitTime(), lockInfo.getLeaseTime(), lockInfo.getTimeUnit() ); if (acquired) { fairnessCache.put(lockInfo.getKey(), lockInfo.isFair()); } return acquired; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } }
private boolean hasFairnessMismatch(LockInfo lockInfo, boolean strictFair) { boolean existingFairness = fairnessCache.compute(lockInfo.getKey(), (k, existing) -> { if (existing != null) { return existing; } return lockInfo.isFair(); });
if (strictFair && existingFairness != lockInfo.isFair()) { log.warn("Lock fairness setting mismatch for key: {}. Existing: {}, Requested: {}", lockInfo.getKey(), existingFairness, lockInfo.isFair()); return true; } return false; }
|
公平性检测的逻辑如下。
- 从 Map 获取 key 对应的公平性,不存在则记录
- 如果严格检查,不一致时直接加锁失败
- 加锁成功后,更新记录,保持记录一致
同时,在释放锁时,如果锁没有被任何线程持有,则清除公平性记录。
1 2 3 4 5 6 7 8 9 10 11 12
| @Override public void unlock(LockInfo lockInfo) { RLock lock = getLock(lockInfo); if (lock.isHeldByCurrentThread()) { lock.unlock(); if (!lock.isLocked()) { fairnessCache.remove(lockInfo.getKey()); } } }
|
现在,我们就实现了对加锁功能的封装,后续代码基于 LockExecutor 这个抽象进行,不需要再关注底层细节。
实现加锁模板
LockTemplate 主要在确定编程式加锁的 API,专注于如何便捷地使用锁。设计上使用模板方法,定义整体加锁和释放锁的流程,让使用者提供业务逻辑。
核心方法如下。
1 2 3 4 5 6 7 8 9 10
| public <T> T execute(LockInfo lockInfo, Supplier<T> supplier) { if (!lockExecutor.tryLock(lockInfo)) { throw new LockException("Failed to acquire lock: " + lockInfo.getKey()); } try { return supplier.get(); } finally { lockExecutor.unlock(lockInfo); } }
|
这里选择 Supplier 作为业务逻辑的抽象,而不是 Callable,因为 Callable 需要抛出异常。在加锁逻辑中,业务的异常应该由业务代码自己处理,加锁时并不需要处理业务逻辑的异常。Supplier 中抛出的异常,应该由 execute 方法的调用者去关注。
为了易用,还需要提供一些重载实现,比如无返回值的业务逻辑,用 Runnable 抽象。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public void execute(String key, Runnable runnable) { execute(key, () -> { runnable.run(); return null; }); }
public void execute(LockInfo lockInfo, Runnable runnable) { execute(lockInfo, () -> { runnable.run(); return null; }); }
public <T> T execute(String key, Supplier<T> supplier) { return execute(createLockInfo(key), supplier); }
|
只提供 key 的版本,使用全局默认配置,由 LockProperties 提供。代码比较简单,不再赘述。
实现 AOP 切面
AOP 切面只需要关注一个功能,获取注解中的配置,然后调用 LockTemplate 执行加锁。调用 LockExecutor 也可以,但代码会复杂一些。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Aspect public class LockAspect { private final LockTemplate lockTemplate;
@Around("@annotation(withLock)") public Object around(ProceedingJoinPoint point, WithLock withLock) { LockInfo lockInfo = parseLockInfo(point, withLock);
return lockTemplate.execute(lockInfo, () -> { try { return point.proceed(); } catch (Throwable throwable) { if (throwable instanceof RuntimeException) { throw (RuntimeException) throwable; } throw new LockException("Error occurred while executing locked method", throwable); } }); } }
|
重点在于解析注解中的配置,解析 SpEL 表达式复杂一些,但也是些模板代码。
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
| private LockInfo parseLockInfo(ProceedingJoinPoint point, WithLock withLock) { try { String key = SpELUtil.parseExpression(withLock.key(), point);
return LockInfo.builder() .key(key) .waitTime(getWaitTime(withLock)) .leaseTime(getLeaseTime(withLock)) .timeUnit(withLock.timeUnit()) .fair(withLock.fair()) .build(); } catch (Exception e) { throw new LockException("Failed to parse lock info", e); } }
public class SpELUtil { private static final ExpressionParser PARSER = new SpelExpressionParser(); private static final DefaultParameterNameDiscoverer NAME_DISCOVERER = new DefaultParameterNameDiscoverer();
public static String parseExpression(String expression, ProceedingJoinPoint point) { MethodSignature signature = (MethodSignature) point.getSignature(); Method method = signature.getMethod(); EvaluationContext context = createContext(point, method); return PARSER.parseExpression(expression).getValue(context, String.class); }
private static EvaluationContext createContext(ProceedingJoinPoint point, Method method) { StandardEvaluationContext context = new StandardEvaluationContext(); Object[] args = point.getArgs(); String[] parameterNames = NAME_DISCOVERER.getParameterNames(method);
for (int i = 0; i < args.length; i++) { context.setVariable(parameterNames[i], args[i]); }
return context; } }
|
解析 SpEL 表达式,需要一个 EvaluationContext。表达式中的用到的参数,都从 Context 中获取,所以需要将方法参数设置到 Context 中。获取参数名也是固定的逻辑。
自动装配
自动装配时有两个重要功能。
一是支持 @EnableLocking 注解。使用 @Conditional 注解,实现 Condition 接口。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| public class LockingEnabledCondition implements Condition {
@Override public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { String propertyValue = context.getEnvironment().getProperty("app.lock.enabled"); if ("true".equalsIgnoreCase(propertyValue)) { return true; }
return context.getBeanFactory().getBeanNamesForAnnotation(EnableLocking.class).length > 0; } }
@AutoConfiguration @EnableConfigurationProperties(LockProperties.class) @Conditional(LockingEnabledCondition.class) public class LockAutoConfiguration { }
|
二是可配置的 LockExecutor 实现类。使用 @ConditionalOnProperty 注解根据配置选择不同的实现类。
1 2 3 4 5 6 7 8 9 10 11 12
| @Bean @ConditionalOnProperty(prefix = "app.lock", name = "type", havingValue = "LOCAL", matchIfMissing = true) public LockExecutor localLockExecutor() { return new LocalLockExecutor(); }
@Bean @ConditionalOnProperty(prefix = "app.lock", name = "type", havingValue = "REDIS") @ConditionalOnClass(RedissonClient.class) public LockExecutor redisLockExecutor(RedissonClient redissonClient, LockProperties lockProperties) { return new RedisLockExecutor(redissonClient, lockProperties.getKeyStorePrefix()); }
|
RedissonClient 由外部环境提供,不需要在当前配置类中初始化。
当然,为了 Spring Boot 能定位 AutoConfiguration 配置类,需要提供配置文件。不同 Spring Boot 版本有不同的配置文件格式。Spring Boot 3.0 使用的是 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
文件。在里面添加 LockAutoConfiguration 类的完全限定名即可。
可扩展点
基于上述流程,已经实现了一个比较完善的锁框架。功能不多,但足够用。相对于直接在业务逻辑中操作锁,代码简洁了一些,可以更加专注于业务代码的实现,降低编程时的心智负担。
有一些可能会需要的扩展方向,简单提供一下思路,因为我还没有遇到这样的需求,所以就没有实现。
锁类型过于单一
理想情况下至少应该支持一下读写锁,在高并发时读写锁的锁粒度更小,读操作性能更好。要实现读写锁,最好是提供一个独立于当前组件的 ReadWriteLockExecutor 接口,在里面定义读写锁的加锁逻辑,然后 ReadWriteLockTemplate 和 @ReadWriteLock 注解。实现时可以提取与排他锁相同的公共逻辑,放到 BaseLockExecutor 之类的抽象类中。不提取也没关系,代码复杂度不会增加太多。
至于其他的锁类型,比如联锁、红锁、分段锁等,实现的逻辑类似,都可以参考现版本实现。
锁实现只能二选一
本地锁和分布式锁只能选其一,其实二者可以共存。实现起来也简单,调整一下 LockTemplate 的实现即可,内部维持多个 LockExecutor 实现类,根据不同的锁类型调用不同的实现。同时,提供多个不同类型的注解 @LocalLock 和 @RedisLock,进一步简化使用。
总结
本文提供了一种在 Spring Boot 中更简便地加锁的封装思路,关键点是基于 AOP 切面和模板方法,实现了一个功能比较完善的锁框架。
完整代码可以从 GitHub 获取,也包含了相关单元测试。