iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0

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。

用 Lua 比對跟刪除

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

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

https://ithelp.ithome.com.tw/upload/images/20261006/20184209X8UsbjmnyH.png

https://ithelp.ithome.com.tw/upload/images/20261006/20184209H7iYcWnRV2.png

https://ithelp.ithome.com.tw/upload/images/20261006/20184209SVZm8d5uGm.png

https://ithelp.ithome.com.tw/upload/images/20261006/20184209BG1jUMiPeB.png

  • TYPE 是 hash,不是 Day 22 那種字串。field 是 UUID:執行緒編號,一樣是用來認是不是自己的鎖。value 的 1 是拿了幾次,同一個執行緒可以重複拿同一把鎖
  • RedisInsight 點 lock:redisson 也看得到,型別是 HASH,field、value 跟上面一樣
  • TTL 是 25、20 一直重複,沒有一路往下掉。降到 20 的時候看門狗就續回 30,5 秒後再查又是 25,過了 30 秒鎖還在
  • 第一個終端機回 做完了 之後,TTL 回 -2,鎖就刪掉了

為什麼要在意

  • 給了過期時間就沒有看門狗。 像 tryLock(0, 10, TimeUnit.SECONDS) 這種給了 10 秒的寫法,10 秒到了鎖就過期,不會續,又回到做太久的問題。要看門狗就不要給過期時間。
  • 鎖不是萬無一失。 App 卡住太久,看門狗跟著停,鎖會過期;Redis 主節點掛掉,鎖還沒傳到從節點,換手後也會不見。鎖不見了,別人就搶得到,所以跟錢有關的,最後還是要靠 DB 擋,例如同一筆交易編號只能寫入一次。

相關範例程式碼可以參考 https://github.com/gary880306/redis-30days/tree/dev

明天

明天開始秒殺:100 件商品、很多人同時搶,來看會不會賣出超過 100 件/images/emoticon/emoticon12.gif


上一篇
Day 23|快取一致性
下一篇
Day 25|秒殺:重現超賣
系列文
從購物車到秒殺:30 天 Redis 高併發自學筆記 共 27 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言