Redis分布式锁
最近工作中涉及到了一个需求:如何防止后端校验中出现重复操作的问题。最初采用了一种将业务逻辑与Redis数据库紧密结合的方式,在校验过程中设置计数锁来实现初步的安全防护。随后,在同事的帮助指导下,将这一功能模块独立化开发。目前公司所参与的项目大多属于分布式系统范畴,并且之前个人在Redis相关技术方面积累较少,在开发过程中也遇到了不少挑战。经过这段时间的努力探索和实践积累,在此进行总结和反思。
什么是分布式锁
要介绍分布式锁,首先要提到与分布式锁相对应的是线程锁、进程锁。
① 线程锁主要用于对操作单元加锁。当某方法或代码使用互斥机制时,在同一时刻只有一个线程可执行该方法或该代码段。由于所有操作都是基于同一个JVM环境进行的,在这个环境中使用互斥机制来控制多态性问题。
为了防止同一操作系统中的多个进程中同时出现竞争性资源访问的情况,在设计进程中引入了专门的互斥机制以保证系统的稳定运行。由于每个进程中都具有独立性特点,在一个进程中所拥有的资源并不为其他进程中所共有或可及,则自然无法通过简单的synchronized等线程锁手段来建立相应的进程互斥机制。
分布式锁的概念:在不同系统的多线程环境中实现对各独立过程资源的统一管理。
为什么需要分布式锁(即要解决什么问题)
随着业务的日益复杂化, 应用服务往往倾向于采用分布式和集群部署策略. 这启示我们, 分布式系统中的CAP原则提示, Consistency (一致性)、Availability (可用性) 和 Partition tolerance (分区容错性) 这三个方面无法同时实现. 实践中, 则可满足CP或AP要求.
在各种场景下,采用分布式事务、分布式锁等相关技术以确保数据的一致性得到最终实现。有时必须保证同一个时间段内不允许同一个方法被多个线程同时执行。
在一个单机环境下,在线程执行过程中如果存在多个线程同时试图对共享变量进行操作时,则可以通过Java提供的同步机制使得不同线程能够在同一时间点安全地访问共享变量。然而,在分布式系统中由于各个进程具有高度独立性因此它们无法像单体环境那样直接实现互斥锁机制这就要求我们通常需要引入一个协调中间节点以便于各参与过程能够基于统一的位置进行读写操作以模拟类似单体环境下的同步控制效果。为了协调多系统对共享资源的同时访问并保证所有参与过程都能基于统一的位置进行读写操作从而能够模拟出类似于单体环境下的同步机制
需要满足什么条件
① 可以确保在分布式部署的应用集群中,在同一时刻同一个方法只允许同一台机器的一个线程执行
② 这种锁若为可重入lock,则能有效避免死锁问题
③ 这种锁最佳选择是一把阻塞式lock
④ 具备高效的获取与释放locking功能
⑤ 其locking performance表现优异
实现方式
主要有三种实现方案:第一方案采用数据库技术作为基础架构,并利用乐观锁机制确保数据一致性;第二方案依赖缓存机制实现快速响应能力,并支持多种缓存服务如redis和memcached;第三方案借助Zookeeper协议进行分布式锁管理以提升系统安全性
本次实践主要选用第二种,基于redis进行实现。
实现思路
粗粒度的大致思路:
① 在对共享变量(key)操作前,判断该变量值在redis中是否有对应的key
若无该key,则将该key存入redis数据库,并为该key设置超时机制,返回true值;
若已有者由其他线程/进程持有,则说明已有者由其他线程/进程持有,
直接返回false值。
总体思路较为简洁明了,在实际开发过程中会面临诸多挑战。其中涉及的关键技术包括具体的加锁策略和位置选择、确保操作的原子性要求较高的机制设计、以及如何规避可能导致死锁风险的问题,在性能优化方面也需要特别注意可能会出现的瓶颈情况。
实现方案
这些方式都没有明确的优劣之别,在实际应用中需根据具体情况作出权衡。从性能角度来看,并非所有方案都能完美契合当前系统需求;此外还需结合与其代码之间的耦合程度以及整体架构设计等其他因素综合考量。
① 以服务的方式提供分布式锁
这种方案通过将加锁与解锁操作整合为一个服务,并将其提供给业务模块使用。该方案使锁服务与业务模块深度融合。实现了对变量访问权限的精细管理,并且确保了操作流程的灵活性。同时保证了较好的性能水平。然而,在技术实现上可能对业务代码有一定的侵入性。
② 以切面的方式提供分布式锁
主要借助自定义注解与Spring框架提供的AOP切面功能相结合,在程序运行过程中前后两端实施拦截措施,在此期间为需要加锁的变量建立协调机制。该种机制能够显著减少对业务系统直接的影响程度,在需要时可较为便捷地扩展系统支持的功能模块。然而这种机制可能带来性能方面的挑战,在具体实施时由于当执行拦截时需要使用反射机制来获取当前注解信息以及方法参数的具体情况而产生的额外开销可能会导致性能下降
自定义注解+Spring AOP+Redis实现分布式锁
本次采用第二种方式,通过自定义注解+Spring AOP+Redis实现
① 自定义注解 RedisLock
package cn.jyycode.annotition;
import java.lang.annotation.*;
/** redis锁
* @author zhangjiayuan@qipeipu.com
* @date 2019/1/29 19:23
* @since 1.0.0
*/
@Documented
@Target({ElementType.METHOD,ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisLock {
/** * 要锁定的属性参数值(key值)索引
* @return
*/
int argsIndex();
}
代码解读
argsIndex在函数形参中代表各个参数的位置编号。其中第一个参数对应的是输入列表的第一个元素(对应于输入列表中的第一个位置编号),第二个参数对应的是输入列表中的第二个元素(对应于输入列表中的第二个位置编号)等
由于我们需要获取加锁的变量值(尤其是非固定锁),而这些值通常由方法参数直接提供(如果要实现对业务逻辑中的对象进行加锁而非依赖于方法参数,则应采用服务化的方式提供分布式锁而非当前这种方法),由此可见,在这种情况下目前的方法也存在一定的局限性。
② RedisLockAspect
@Slf4j
@Aspect
@Component
public class RedisLockAspect {
@Autowired
private StringRedisTemplate stringRedisTemplate;
代码解读
采用Spring AOP技术,并配置RedisLock注解作为拦截切面。同时将Redis服务通过SpringBean注解注入到系统中。
③ 设置超时时间
/** * 设置key超时时间,默认为5s
*/
private static final int LOCK_EXPIRE_SECONDS = 5;
代码解读
具体超时时间根据业务需求而定了,这里默认5s
④ 设置拦截切点
/** * 要锁定的操作切点
*/
@Pointcut("@annotation(cn.jyycode.annotition.RedisLock)")
public void lockAspect() {
}
代码解读
对注解了RedisLock的方法进行拦截
⑤ 方法执行前
/** * 用于拦截操作,在方法执行前执行
* * @param joinPoint 切点
*/
@Before(value = "lockAspect()")
public void before(JoinPoint joinPoint) throws Throwable {
this.handle(joinPoint);
}
代码解读
对拦截的方法进行代理增强,进行相关逻辑的操作
⑥ 加锁判断
/** * redis分布式锁
* * @param joinPoint 连接点
* @return
*/
private void handle(JoinPoint joinPoint) throws Throwable {
RedisLock annotation = this.getAnnotation(joinPoint);
Object[] args = joinPoint.getArgs();
boolean result = false;
try {
key = String.valueOf(args[Optional.ofNullable(annotation.argsIndex()).orElse(0)]);
result = this.lock(key, LOCK_EXPIRE_SECONDS);
if (!result) {
log.info("【锁定失败】:{}", "要操作的属性值已被锁定");
throw new RedisLockException("【锁定失败】:要操作的属性值已被锁定");
} else {
log.info("【成功加锁】");
}
} catch (Exception e) {
}
}
代码解读
解析该方法的相关注解信息,并提取待锁定对象各参数的索引数值;随后利用切片点处的参数数组定位目标对象。
2. 执行lock加锁操作,若加锁成功则返回true,失败则返回false
⑦ 加锁操作(重点)
/** * 加锁
* * @param key
* @param expire 过期时间,单位秒
* @return true:加锁成功,false:加锁失败
*/
private boolean lock(String key, int expire) {
String oldValue = stringRedisTemplate.opsForValue().getAndSet(key, String.valueOf(expire));
stringRedisTemplate.expire(key, expire, TimeUnit.SECONDS);
if (oldValue != null) {
return false;
}
return true;
}
代码解读
1.对redis进行getAndSet操作,该操作是原子性的,获取旧值并设置新值
检查所获取的旧值是否为空:
- 若为空,则表明是首次加锁(未有其他线程/进程设置该变量),则表示加锁成功并返回true;
- 若旧值不为空,则说明上一个线程/进程已经在使用该变量,并且还在继续使用或没有超时限制,则本次应返回加锁失败(即使设置了新的值)并返回false。
⑧ 获取方法上的注解
/** * 获取方法上的注解
* * @param joinPoint 连接点
* @return 返回方法注解
* @throws Exception
*/
private RedisLock getAnnotation(JoinPoint joinPoint) {
Method method = null;
try {
method = Optional.ofNullable(((MethodSignature) joinPoint.
getSignature()).
getMethod()).orElse(null);
} catch (Exception ignore) {
log.info("【反射当前方法失败】:{}", ignore);
ignore.printStackTrace();
throw new RedisLockException("【反射当前方法失败】");
}
if (method == null) {
throw new RedisLockException("【获取方法注解异常】");
}
return method.getAnnotation(RedisLock.class);
}
代码解读
⑨ 自定义异常
/** redis锁异常
* @author zhangjiayuan@qipeipu.com
* @date 2019/1/30 10:00
* @since 1.0.0
*/
@NoArgsConstructor
public class RedisLockException extends RuntimeException {
public RedisLockException(String message){
super(message);
}
}
代码解读
用于统一处理此类异常
基于redisson实现分布式锁
Redis official provides a Java-based implementation of Redis lock through the project known as redisson, which primarily supports various connection methods.
Cluster(集群)
Sentinel servers(哨兵)
Single server(单机)
本次测试采用简单的单机连接,实践redisson的简单使用
① 导入maven依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>2.7.0</version>
</dependency>
代码解读
② 程序化配置方法:
Redison程序化配置方式是基于构建Config$对象实例实现的;利用RedissoManager生成redisso对象。
/**获取redisson对象
* @author zhangjiayuan@qipeipu.com
* @date 2019/2/13 10:39
* @since 1.0.0
*/
public class RedissonManager {
private static Config config = new Config();
//声明redisson对象
private static Redisson redisson = null;
//实例化
static{
config.useSingleServer().setAddress("ip:6379");
redisson = (Redisson)Redisson.create(config);
}
//获取redisson对象的方法
public static Redisson getRedisson(){
return redisson;
}
}
代码解读
③ 从RedissonManager获取redisson对象,并编写redisson加锁,解锁操作
/** * 高性能分布式锁redisson的使用
* * @author zhangjiayuan@qipeipu.com
* @date 2019/2/13 10:45
* @since 1.0.0
*/
public class DistributeRedisLock {
//从配置类中获取redisson对象
private static Redisson redisson = RedissonManager.getRedisson();
private static final String LOCK_TITLE = "redisLock_";
//加锁
public static boolean acquire(String lockName) {
//声明key对象
String key = LOCK_TITLE + lockName;
//获取锁对象
RLock myLock = redisson.getLock(key);
//加锁,并且设置锁过期时间,防止死锁的产生
myLock.lock(5, TimeUnit.SECONDS);
System.out.println("=====lock=====" + Thread.currentThread().getName());
//加锁成功
return true;
}
//锁的释放
public static void release(String lockName) {
//必须是和加锁同一个key
String key = LOCK_TITLE + lockName;
//获取锁对象
RLock myLock = redisson.getLock(key);
//释放锁(解锁)
myLock.unlock();
System.out.println("=====unlock====="+Thread.currentThread().getName());
}
}
代码解读
④ 业务逻辑
/** * @author zhangjiayuan@qipeipu.com
* @date 2019/2/13 10:54
* @since 1.0.0
*/
@RestController
@RequestMapping("/redisLock")
@Slf4j
public class RedissonController {
@RequestMapping("/get")
public void redissonLock(){
String key = "test";
//加锁
DistributeRedisLock.acquire(key);
//执行具体业务逻辑
System.out.println("dosomething()");
//释放锁
DistributeRedisLock.release(key);
}
}
代码解读
⑤ 测试结果,跑10个线程,均能够在指定业务同步操作中串行访问

存在的问题及不足(时间仓促,待完善)
① 上述写的很粗糙,需要完善,大致完善点如下
② 对上面写的内容进行补充和review
③ 需要更具合理性地设计加锁机制(例如考虑锁失效的情况、超时问题以及是否由人工干预解锁等),操作原子性也包括
④ 潜在的死锁问题分析
⑤ 本次是悲观锁实现,性能差,需要寻求乐观锁实现(配合分布式事务)
⑥ 实践spring官方提供的一种解决方案redisson
⑦ 总结遇到的问题及其改进措施。例如将utils替换为自定义注解并结合AOP拦截技术,在灵活设置锁值的同时调整方法策略。通过多种注解手段实现对非固定锁值的有效获取,并对空指针、数组越界以及边界条件等情况进行异常处理以确保系统的健壮性与稳定性。
