Day 22 用 SET NX 做了一把互斥鎖,只讓一個人去查 DB,那時候說過這把鎖還有問題。今天把它補完。
先說為什麼鎖要放在 Redis。Java 有 synchronized,但它只鎖得住同一台 App。App 通常不只一台,每台各鎖各的,三台還是可以同時進來。三台都連同一台 Redis,鎖放在 Redis,搶的才是同一把。這種很多台 App 一起搶的鎖,叫分散式鎖。
例如每天晚上的排程,三台 App 都會跑到,但只能有一台做。下面的例子 A、B、C 是三台 App,搶同一把鎖,鎖 10 秒過期,由上往下照時間順序排。
鎖一定要設過期時間,搶到鎖的人掛掉,鎖才會自己不見。
最直覺的寫法是先 SETNX 搶鎖,搶到再下 EXPIRE 設過期時間(SETNX 本身不能設過期時間)。如果剛好在這兩個指令中間掛掉:
A:SETNX lock:job 1,搶到鎖
A:還沒 EXPIRE 就掛掉了
B:SETNX lock:job 1,沒搶到
--------------------
鎖沒有過期時間,之後誰都搶不到
所以搶鎖跟設過期時間要用一個指令做完:SET lock:job 1 EX 10 NX。setIfAbsent 有給過期時間的話,送出去的就是這一個指令。
上面的鎖 value 都放 1,做完直接 DEL。如果 A 做太久,超過 10 秒:
A:搶到鎖,開始做
A:做到第 10 秒,鎖過期了(還沒做完)
B:搶到鎖,開始做
A:做完了,刪掉鎖
C:搶到鎖,開始做
--------------------
B 跟 C 同時在做
A 刪的時候,鎖已經是 B 的了。但 value 都是 1,A 分不出來,把 B 的鎖刪掉,C 又搶到了。
所以 value 改放每個人自己的 UUID,刪之前先 GET 出來比對,是自己的才 DEL。
GET 跟 DEL 是兩個指令。A GET 完確定是自己的,還沒 DEL 之前鎖剛好過期、被 B 搶走,A 一樣會刪到 B 的。
跟扣庫存一樣,用 Lua 把比對跟刪除一次做完,中間不會被插隊。新增 resources/scripts/unlock.lua:
-- Day 24:鎖還是自己的才刪,比對跟刪除一次做完
-- KEYS[1]:鎖的 key,例如 lock:job
-- ARGV[1]:搶鎖時放進去的 UUID
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1]) -- 是自己的,刪掉,回 1
end
return 0 -- 鎖已經不是自己的了,不動
新增 LockController,搶到鎖之後 sleep 指定的秒數,當作在做事:
private static final Duration LOCK_TTL = Duration.ofSeconds(10);
/** 比對跟刪除一次做完 */
private static final RedisScript<Long> UNLOCK =
RedisScript.of(new ClassPathResource("scripts/unlock.lua"), Long.class);
/** 搶到鎖做 seconds 秒,做完是自己的鎖才刪 */
@PostMapping
public Map<String, Object> lock(@RequestParam int seconds) throws InterruptedException {
String uuid = UUID.randomUUID().toString(); // 每次搶鎖都不一樣,刪的時候才認得出是不是自己的
if (!Boolean.TRUE.equals(redis.opsForValue().setIfAbsent("lock:job", uuid, LOCK_TTL))) {
return Map.of("result", "沒搶到");
}
try {
Thread.sleep(seconds * 1000L); // 模擬拿到鎖之後要做的事
} finally {
redis.execute(UNLOCK, List.of("lock:job"), uuid); // List 裡的是 KEYS,後面接的是 ARGV
}
return Map.of("result", "做完了");
}
之前只傳了 KEYS,這次多傳一個自己的 UUID,在腳本裡就是 ARGV[1]。
UUID 只能讓 A 不會刪到別人的鎖。A 做到第 10 秒鎖過期、B 搶到的時候,A 其實還在做,A 跟 B 還是同時在做,這就是 Day 22 說的坑。
把過期時間設長一點也不行,設多長都可能不夠。設太長,搶到鎖的人掛掉,其他人又要等很久。
比較好的做法是看門狗:搶到鎖之後,旁邊另外有人每隔一段時間看一下,還在做就把過期時間加回去。App 掛掉的話,看門狗也跟著停,鎖就會照常過期。
自己寫的話,要多一個排程,再加一支續期的 Lua。這部分可以直接用現成的 Redisson。
Redisson 是 Java 的 Redis client,鎖跟看門狗都寫好了。這裡只拿它的鎖來用,其他還是用 Spring Data Redis。
pom.xml 加上:
<!-- Day 24:分散式鎖。用 3.x 最後一版,跟 Spring Boot 3.4 用同一版 Netty -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.52.0</version>
</dependency>
新增 config/RedissonConfig,建一個 RedissonClient:
@Bean
public RedissonClient redisson() {
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379"); // 跟 application.yml 連同一台
config.setLazyInitialization(true); // 用到才連線,之前的 Sentinel、Cluster 沒開這台也起得來
return Redisson.create(config);
}
LockController 再加一個 /lock/redisson:
/** 不給過期時間,Redisson 就會用看門狗:鎖 30 秒,還在做就每 10 秒續回 30 秒 */
@PostMapping("/redisson")
public Map<String, Object> redisson(@RequestParam int seconds) throws InterruptedException {
RLock lock = redisson.getLock("lock:redisson");
if (!lock.tryLock()) {
return Map.of("result", "沒搶到");
}
try {
Thread.sleep(seconds * 1000L); // 做超過 30 秒,鎖也不會過期
} finally {
lock.unlock(); // 一樣是自己的才刪
}
return Map.of("result", "做完了");
}
tryLock() 跟 setIfAbsent 一樣,沒搶到馬上回 false。unlock() 裡面也是一支 Lua,先確認是自己的鎖才刪。
重新啟動 Java,搶一把鎖做 40 秒,比看門狗的 30 秒還久(在主機的終端機下):
curl -X POST "localhost:8080/lock/redisson?seconds=40"
馬上開另一個終端機看這把鎖。-r 9 -i 5 是讓 redis-cli 每 5 秒下一次 TTL,總共下 9 次(在主機的終端機下):
docker exec redis30days redis-cli TYPE lock:redisson
docker exec redis30days redis-cli HGETALL lock:redisson
docker exec redis30days redis-cli -r 9 -i 5 TTL lock:redisson




TYPE 是 hash,不是 Day 22 那種字串。field 是 UUID:執行緒編號,一樣是用來認是不是自己的鎖。value 的 1 是拿了幾次,同一個執行緒可以重複拿同一把鎖lock:redisson 也看得到,型別是 HASH,field、value 跟上面一樣TTL 是 25、20 一直重複,沒有一路往下掉。降到 20 的時候看門狗就續回 30,5 秒後再查又是 25,過了 30 秒鎖還在做完了 之後,TTL 回 -2,鎖就刪掉了tryLock(0, 10, TimeUnit.SECONDS) 這種給了 10 秒的寫法,10 秒到了鎖就過期,不會續,又回到做太久的問題。要看門狗就不要給過期時間。相關範例程式碼可以參考 https://github.com/gary880306/redis-30days/tree/dev
明天開始秒殺:100 件商品、很多人同時搶,來看會不會賣出超過 100 件![]()