一,前言
redis 現在已經成爲系統緩存的必備組件,針對緩存讀取更新操作,通常我們希望當緩存過期之後能夠只有一個請求去更新緩存,其它請求依然使用舊的數據。這就需要用到鎖,因爲應用服務多數以集羣方式部署,因此這裏的鎖就必需要是分佈式鎖才能符合需求。
二,spring-boot 引入 redis
在 pom 文件中加入如下依賴,spring-boot 的自動註冊功能會幫我們準備好,我們直接使用 StringRedisTemplate 就可以了。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
三,redis 分佈式鎖實現
/** * @author koma <[email protected]> * @date 2018-09-19 11:24 */ @Slf4j @Service public class CacheService { private static final Long RELEASE_SUCCESS = 1L; private static final String LOCK_SUCCESS = "OK"; private static final String SET_IF_NOT_EXIST = "NX"; private static final String SET_WITH_EXPIRE_TIME = "EX"; private static final String RELEASE_LOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; @Autowired private StringRedisTemplate redisTemplate; /** * 該加鎖方法僅針對單實例 Redis 可實現分佈式加鎖 * 對於 Redis 集羣則無法使用 * * 支持重複,線程安全 * * @param lockKey 加鎖鍵 * @param clientId 加鎖客戶端唯一標識(採用UUID) * @param seconds 鎖過期時間 * @return */ public Boolean tryLock(String lockKey, String clientId, long seconds) { redisTemplate.opsForValue().set(); return redisTemplate.execute((RedisCallback<Boolean>) redisConnection -> { Jedis jedis = (Jedis) redisConnection.getNativeConnection(); String result = jedis.set(lockKey, clientId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, seconds); if (LOCK_SUCCESS.equals(result)) { return Boolean.TRUE; } return Boolean.FALSE; }); } /** * 與 tryLock 相對應,用作釋放鎖 * * @param lockKey * @param clientId * @return */ public Boolean releaseLock(String lockKey, String clientId) { return redisTemplate.execute((RedisCallback<Boolean>) redisConnection -> { Jedis jedis = (Jedis) redisConnection.getNativeConnection(); Object result = jedis.eval(RELEASE_LOCK_SCRIPT, Collections.singletonList(lockKey), Collections.singletonList(clientId)); if (RELEASE_SUCCESS.equals(result)) { return Boolean.TRUE; } return Boolean.FALSE; }); } }
上述代碼實現,僅對 redis 單實例架構有效,當面對 redis 集羣時就無效了。但是一般情況下,我們的 redis 架構多數會做成“主備”模式,然後再通過 redis 哨兵實現主從切換,這種模式下我們的應用服務器直接面向主機,也可看成是單實例,因此上述代碼實現也有效。但是當在主機宕機,從機被升級爲主機的一瞬間的時候,如果恰好在這一刻,由於 redis 主從複製的異步性,導致從機中數據沒有即時同步,那麼上述代碼依然會無效,導致同一資源有可能會產生兩把鎖,違背了分佈式鎖的原則。
redis 單實例架構示意圖
爲什麼上面的代碼可以實現分佈式鎖,根本原因在於 redis 對 set 命令中的 NX 選項和對 lua 腳本的執行都是原子的,因此當多個客戶端去爭搶執行上鎖或解鎖代碼時,最終只會有一個客戶端執行成功。同時 set 命令還可以指定key的有效期,這樣即使當前客戶端奔潰,過一段時間鎖也會被 redis 自動釋放,這就給了其它客戶端獲取鎖的機會。
上述代碼不能使用 spring-boot 提供的 redisTemplate.opsForValue().set() 命令是因爲 spring-boot 對 jedis 的封裝中沒有返回 set 命令的返回值,這就導致上層沒有辦法判斷 set 執行的結果,因此需要通過 execute 方法調用 RedisCallback 去拿到底層的 Jedis 對象,來直接調用 set 命令。這個問題主要是在 spring-data-redis 的封裝上,瞭解即可。
四,分佈式鎖的原則
獨享: 即互斥屬性,在同一時刻,一個資源只能有一把鎖被一個客戶端持有
無死鎖: 當持有鎖的客戶端奔潰後,鎖仍然可以被其它客戶端獲取
容錯性: 當部分節點失活之後,其餘節點客戶端依然可以獲取和釋放鎖
統一性: 即釋放鎖的客戶端只能由獲取鎖的客戶端釋放
五,一類常見錯誤實現和推薦使用方式
if (redisTemplate.opsForValue().setIfAbsent(lockKey, clientId)) { //這裏存在宕機風險,導致設置有效期失敗 redisTemplate.expire(lockKey, seconds, TimeUnit.SECONDS); }
這是一種典型的錯誤實現,在早期的 redis 分佈式鎖實踐中我們經常可以看到類似的實現,其中 spring-boot 中的 setIfAbsent 方法在底層調用的是 redis 的 setNx 命令,該命令和 set 命令的 NX 選項具有同樣的功能,但是 setNx 命令不能夠設置 key 的有效期,這也是爲什麼我們會在獲取到鎖之後馬上去設置鎖的有效期,但是恰好這裏卻隱藏着風險,因爲這一整個操作並非是原子的。
if (clientId.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); }
對於解鎖代碼,也存在同樣的風險,因爲在執行 delete 的時候,lockKey 現在可能已經被另外一個客戶端持有了,那麼這裏直接刪除就是刪除了其它客戶端的鎖,導致的最終結果就是真正應該持有鎖的客戶端在沒有完全執行完之後,鎖又被另外的客戶端持有了,這樣一個資源就產生了兩把鎖,同樣違背了分佈式鎖的原則。
推薦的使用方式是,當 redis 的架構如上圖所示一樣是單實例模式時,如果存在主備且可以忍受小概率的鎖出錯,那麼就可以直接使用上述代碼,當然最嚴謹的方式還是使用官方的 Redlock 算法實現。其中 Java 包推薦使用 redisson。
六,參考資料