前言:为什么需要分布式锁?
在现代分布式系统中,多个服务实例同时访问共享资源已成为常态。想象一下电商平台的秒杀场景:成千上万的请求同时涌入,试图扣减同一商品的库存。如果没有有效的并发控制机制,极可能导致超卖问题------库存减为负数,这显然是业务无法接受的。
单机环境下,我们可以使用语言内置的锁机制(如Java的synchronized或ReentrantLock)解决并发问题。但在分布式系统中,这些本地锁无法跨进程、跨服务器生效,这时就需要分布式锁登场了。
Redis以其高性能、原子操作和丰富的数据结构,成为实现分布式锁的热门选择。本文将深入探讨基于Redis的分布式锁实现方案,从基础实现到生产级优化,帮助你在分布式环境中构建可靠的并发控制机制。
问题探究
现状:用户针对同一张单据重复操作(比如:重复点击提交),导致数据存储重复,虽然在前端做了防重复提交,但该问题还是偶发,由此引入分布式锁。
分布式锁的简单实现
由于系统中存在数据权限控制,所以分布式锁需要控制到用户+单据级别
一、创建自定义注解类
less
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RedissonLock {
/**
* 锁名称前缀(默认方法名)
*/
String name() default "resubmit_lock";
/**
* 唯一键的 SpEL 表达式(从方法参数中提取)
* 示例: "#param.id" 或 "#id"
* 示例: "#param1.id + '_' + #param2.id"
*/
String key() default "";
/**
* 锁自动释放时间(默认 30 秒)
*/
long leaseTime() default 10;
/**
* 锁自动释放时间单位(默认秒)
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 获取锁失败时提示信息
*/
String message() default "系统繁忙,请稍后再试";
}
二、创建自定义切面
java
@Aspect
@Component
public class RedissonLockAspect {
private final RedissonClient redissonClient;
private final SpelExpressionParser spelParser = new SpelExpressionParser();
private final LocalVariableTableParameterNameDiscoverer parameterNameDiscoverer = new LocalVariableTableParameterNameDiscoverer();
public RedissonLockAspect(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
@Around("@annotation(redissonLock)")
public Object around(ProceedingJoinPoint joinPoint, RedissonLock redissonLock) throws Throwable {
// 1. 构造锁的 Key
String lockKey = buildLockKey(joinPoint, redissonLock);
// 2. 获取分布式锁
RLock lock = redissonClient.getLock(lockKey);
try {
// 3. 尝试加锁(非阻塞式)
boolean isLocked = lock.tryLock(0, redissonLock.leaseTime(), redissonLock.timeUnit());
if (!isLocked) {
throw new OperateException(redissonLock.message()); // 自定义业务异常
}
// 4. 执行原方法
return joinPoint.proceed();
} finally {
// 5. 释放锁(确保当前线程持有锁)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
/**
* 构建锁的 Key 格式: lock:{name}:{account}:{keyValue}
*
* @param joinPoint ProceedingJoinPoint
* @param redissonLock RedissonLock
* @return java.lang.String
**/
private String buildLockKey(ProceedingJoinPoint joinPoint, RedissonLock redissonLock) {
// 获取当前用户 ID(需根据实际系统实现)
String account = StpUtil.getSession().get("account").toString();
// 解析 SpEL 表达式获取动态 key
String dynamicKey = parseSpel(redissonLock.key(), joinPoint);
// 组合完整 Key
String prefix = redissonLock.name().isEmpty()
? joinPoint.getSignature().getName()
: redissonLock.name();
return String.format("lock:%s:%s:%s", prefix, account, dynamicKey);
}
/**
* 解析 SpEL 表达式
*
* @param spel String
* @param joinPoint ProceedingJoinPoint
* @return java.lang.String
**/
private String parseSpel(String spel, ProceedingJoinPoint joinPoint) {
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
String[] paramNames = parameterNameDiscoverer.getParameterNames(method);
if (paramNames == null || spel.isEmpty()) {
return "";
}
Expression expression = spelParser.parseExpression(spel);
return expression.getValue(createContext(method, joinPoint.getArgs()), String.class);
}
/**
* 创建 SpEL 上下文
*
* @param method Method
* @param args Object[]
* @return org.springframework.expression.EvaluationContext
**/
private EvaluationContext createContext(Method method, Object[] args) {
// 获取方法参数名
String[] paramNames = parameterNameDiscoverer.getParameterNames(method);
// 构建参数名-值映射
Map<String, Object> variables = new HashMap<>();
if (paramNames != null) {
for (int i = 0; i < paramNames.length; i++) {
variables.put(paramNames[i], args[i]);
}
}
// 使用 StandardEvaluationContext(注意安全风险)
StandardEvaluationContext context = new StandardEvaluationContext();
context.setVariables(variables);
return context;
}
}
三、接口方法增加注解
less
//todoTaskRequest-请求入参,taskId-需要加锁的key(保证唯一)
@PostMapping("/complete")
@Operation(summary = "完成任务")
@RedissonLock(key = "#todoTaskRequest.taskId", message = "请勿重复审批")
public AjaxResult<Object> completeTask(@RequestBody TodoTaskRequest todoTaskRequest) {
taskServiceImpl.complete(todoTaskRequest.getTaskId(), todoTaskRequest.getVariables());
return AjaxResult.success("任务处理完成");
}
与Zookeeper分布式锁对比
维度 | Redis | Zookeeper |
---|---|---|
一致性模型 | 最终一致性 | 强一致性 |
性能 | 10w+ QPS | 1w+ QPS |
锁实现方式 | 临时键值 | 临时顺序节点 |
适用场景 | 高频短耗时操作 | 低频长事务操作 |
故障恢复 | 依赖异步复制 | 快速选举恢复 |
常见问题与解决方案
一、锁过期但业务未执行完怎么办?
方法 | 实现方式 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
锁续期(Watch Dog) | 获取锁后启动后台线程,定期检查业务是否执行完,若未完成则延长锁过期时间(如Redisson的lock() ) |
- 自动续期,减少锁提前释放风险 - 开源框架(如Redisson)已内置支持,使用方便 | - 需要额外线程/协程维护 - 客户端崩溃时仍可能无法续期 | 适用于业务执行时间波动较大的场景 |
合理设置超时时间 | 预估业务执行时间(如95%分位数),设置锁过期时间 = 3~5倍业务时间(SET key value NX PX 30000 ) |
- 实现简单,无额外开销 - 适合执行时间稳定的业务 | - 无法应对极端情况(如网络抖动) - 过长超时会降低并发性能 | 适用于执行时间稳定的短任务 |
异步续期+心跳检测 | 客户端获取锁后定期发送心跳,若业务完成则停止续期,崩溃时心跳停止导致锁自动过期 | - 可灵活控制续期逻辑 - 客户端崩溃后锁能自动释放 | - 实现复杂,需维护心跳线程 - 可能因网络问题误判 | 需要精细控制锁生命周期的场景 |
锁版本号/令牌机制 | 获取锁时生成随机Token,每次续期更新Token,释放锁时校验Token是否匹配 | - 避免误删其他客户端的锁 - 安全性更高 | - 实现较复杂 - 需保证Token的唯一性和一致性 | 对安全性要求高的分布式锁场景 |
业务拆分 | 将长任务拆分为多个短任务,每个子任务独立加锁(如分阶段提交) | - 避免长时间占用锁 - 提高系统整体并发能力 | - 业务改造成本高 - 需保证子任务间的状态一致性 | 适用于可拆分的长时间任务 |
二、Redis主从切换导致的锁失效问题
方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
Redlock算法 | 部署多个独立Redis节点,客户端向多数节点(N/2+1)获取锁成功才算有效 | - Redis官方推荐 - 容忍部分节点故障 - 理论可靠性高 | - 实现复杂 - 需要5个以上节点 - 性能较低(需多节点通信) - 时钟漂移问题 | 金融、交易等高一致性要求的场景 |
哨兵模式+锁续期 | 结合Redis哨兵监控,主从切换时通过Watch Dog自动续期锁,避免切换期间锁失效 | - 实现相对简单 - 开源框架(如Redisson)支持 - 平衡性能与可靠性 | - 主从切换瞬间仍可能丢锁 - 依赖哨兵配置和监控 | 大多数业务场景(如订单、库存) |
Raft-based实现 | 使用强一致性的Redis变种(如KeyDB)或etcd/ZooKeeper,基于Raft协议保证数据一致性 | - 强一致性保证 - 自动故障转移 - 无需额外算法 | - 需更换Redis实现 - 性能可能下降 - 运维复杂度高 | 对一致性要求极高的基础设施场景 |
业务层容错设计 | 通过幂等性、乐观锁、补偿机制等业务逻辑降低锁失效的影响 | - 不依赖Redis特性 - 通用性强 - 成本低 | - 业务改造成本高 - 无法完全避免并发问题 | 锁失效风险可接受的业务场景 |
结语
没有完美的分布式锁方案,需要根据业务需求权衡选择:高并发用Redis+哨兵,强一致用Redlock或etcd,最终兜底靠业务容错。