問題背景
對於大部分的後端服務,服務很多都是多實例部署的,而在我們的工程中,比如說代碼中定義的定時任務需要從數據庫中撈數據,那麼多機部署上的每個實例都會執行,那麼就會存在數據重複上報,那麼就不可避免得出現髒數據,影響數據的準確性。
問題分析
解決這個問題最直接的思路就是,當多機部署中無論哪個實例撈到了某條數據,那麼其他的實例就不能再次撈取。針對這個思路,我們大致可以有以下兩個解決方案:
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.”日誌。說明一個獲取到鎖,而另一個並未獲取到。也就達到了分佈式鎖預期的效果。