问题背景
对于大部分的后端服务,服务很多都是多实例部署的,而在我们的工程中,比如说代码中定义的定时任务需要从数据库中捞数据,那么多机部署上的每个实例都会执行,那么就会存在数据重复上报,那么就不可避免得出现脏数据,影响数据的准确性。
问题分析
解决这个问题最直接的思路就是,当多机部署中无论哪个实例捞到了某条数据,那么其他的实例就不能再次捞取。针对这个思路,我们大致可以有以下两个解决方案:
1. 在数据库层解决。给访问的数据表中,增一个字段flag,标识是否已经上报过,每个实例上报一条,就更新一下这个状态,后面的实例再读到这一条时,发现上报过了,就不处理了。这就要改一下现在的读取方式,需要从数据库中逐条读取,避免脏数据。
2. 引入分布式锁,最简单的,就用redis实现,每个实例开始定时任务前,先尝试在redis中获取锁,如果获取得到,这个实例的定时任务就执行,否则就跳过。
对于第一个解决方案的实现本文不予说明,这种实现方式虽然可行,但是对于频繁更新数据库的操作我个人是非常不推荐的,性能方面可能会受到影响。本文主要想介绍一下方案2的实现。
方案实现
对于多机部署的问题,自然而然就可以想到分布式锁来保证任务执行的准确性。在Springboot项目中,最直接就是通过redis实现,我们知道,从Springboot 2.X开始,底层的redis客户端从Jedis换成了luttuce,对于低版本的Jedis的一些API可能无法兼容了,建议直接基于luttuce进行开发。
1、首先要做的就是需要在pom文件中加入你所需要的redis依赖,注意,在springboot 2.x之前,一般依赖的是spring-boot-starter-redis,在2.x,建议依赖spring-boot-starter-data-redis。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
pom文件中的依赖搞完后,还需要在application.properties中配置redis相关配置,如host,port,password等信息。这里我就不展开说了。
2、创建分布式锁
/**
* redis分布式锁
*
*/
@Slf4j
@Component
public class RedisDistributedLock {
/**
* 锁定时长(单位:秒)
*/
public static final long LOCK_TIME = 60 * 60L;
/**
* 释放锁脚本
*/
private static final String UNLOCK_LUA;
@Autowired
private RedisTemplate<String, String> redisTemplate;
static {
StringBuilder sb = new StringBuilder();
sb.append("if redis.call(\"get\",KEYS[1]) == ARGV[1] ");
sb.append("then ");
sb.append(" return redis.call(\"del\",KEYS[1]) ");
sb.append("else ");
sb.append(" return 0 ");
sb.append("end ");
UNLOCK_LUA = sb.toString();
}
/**
* redis set操作
*
* @param key
* @param expire
* @return 返回结果
*/
private boolean setRedis(String key, long expire) {
RedisCallback<Boolean> callback = connection -> connection.set(key.getBytes(StandardCharsets.UTF_8),
key.getBytes(StandardCharsets.UTF_8), Expiration.seconds(expire),
RedisStringCommands.SetOption.SET_IF_ABSENT);
return redisTemplate.execute(callback);
}
/**
* redis加锁操作
*
* @param key key
* @param expire 过期时间
* @return 返回结果
*/
public boolean lock(String key, long expire) {
log.info("Get lock '{}'.", key);
return this.setRedis(key, expire);
}
/**
* redis释放锁
*
* @param lockKey key
* @return 返回结果
*/
public boolean releaseLock(String lockKey) {
log.info("release lock '{}'", lockKey);
RedisCallback<Boolean> callback = connection -> connection.eval(UNLOCK_LUA.getBytes(StandardCharsets.UTF_8),
ReturnType.BOOLEAN, 1, lockKey.getBytes(StandardCharsets.UTF_8),
lockKey.getBytes(StandardCharsets.UTF_8));
return redisTemplate.execute(callback);
}
}
3、为了信息搜集更为方便,可以创建分布式锁的注解,并创建对应的切面
Lock注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Lock {
/**
* redis库中标识符key
*
* @return value
*/
String value() default "";
}
Lock信息解析切面Aspect,在创建切面之前需在pom中加入aop的依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
@Slf4j
@Aspect
@Component
public class LockParseAspect {
@Autowired
private RedisDistributedLock redisLock;
/**
* 切面拦截@Lock注解并进行加锁
*
* @param joinPoint 切入点
* @param lock lock注解
* @return 返回结果
* @throws Throwable 异常
*/
@Around(value = "@annotation(com.xxx.model.Lock) && @annotation(lock))",argNames = "joinPoint, lock")
public Object proceed(ProceedingJoinPoint joinPoint, Lock lock) throws Throwable {
Object result = null;
if (StringUtils.hasText(lock.value())) {
if (redisLock.lock(lock.value(), RedisDistributedLock.LOCK_TIME)) {
try {
result = joinPoint.proceed();
} finally {
redisLock.releaseLock(lock.value());
}
} else {
log.info("Lock failed, maybe another instance is calculating data.");
}
}
return result;
}
}
说明:第3个步骤是可选的,只是为了在加锁和释放锁的时候信息能搜集得更多。
分布式锁的使用
上面准备工作做完了,分布式锁即可用起来了,使用的方法很简单,只需要在需要进行加锁的方法上加上Lock的注解,例如@Lock(key)。
测试的方法也很简单,在你IDE中或者机器上把工程启动2次(在不同的端口启动,例如8080和8081),将你想测试的方法做定时执行处理(cron表达式设置到你测试的时间即可),观察IDE的打印台上log打印,加锁和解锁正常的情况下,会打印一个端口的服务获取到锁并执行方法内的代码逻辑并在执行完后释放锁,而另一个端口会打印获取锁失败“Lock failed, maybe another instance is calculating data.”日志。说明一个获取到锁,而另一个并未获取到。也就达到了分布式锁预期的效果。