iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0

Day 18 最後說到,不管單機還是 Cluster,指令都是一個一個送:送出去、等回覆、再送下一個。Redis 執行一個 SET 很快,時間大多花在來回的路上。

今天看兩種把指令包在一起送的做法:Pipeline 省來回的時間,MULTI 讓一串指令中間不被插隊。MULTI 常被叫做 Redis 的事務(transaction,跟資料庫的「交易」是同一個英文字),但它跟資料庫的交易不一樣,出錯不會回滾。

常用指令

  • MULTI(開始排隊,後面的指令先不執行)
  • EXEC(排好隊的一口氣執行)
  • WATCH key(盯著這個 key,EXEC 之前被別人改過就整批不做)

先回到單機

先把 Day 18 的 Cluster 關掉,開回 Day 2 的 redis30days(在主機的終端機下):

docker compose -f docker-compose-cluster.yml down

docker compose up -d

Java 的 Program arguments 把 --spring.profiles.active=cluster 拿掉,改連回 6379。

Pipeline

新增一個 PipelineController,一樣寫 n 筆,一個一筆一筆送,一個用 Pipeline:

/** 一筆一筆送:送出去、等回覆,才送下一筆 */
@PostMapping("/one")
public Map<String, Object> one(@RequestParam int n) {
    long start = System.currentTimeMillis();
    for (int i = 1; i <= n; i++) {
        redis.opsForValue().set("pipe:" + i, "1");
    }
    return Map.of("n", n, "ms", System.currentTimeMillis() - start);
}

/** Pipeline:n 筆先全部送出去,最後再一次收回覆 */
@PostMapping("/batch")
public Map<String, Object> batch(@RequestParam int n) {
    long start = System.currentTimeMillis();
    redis.executePipelined((RedisCallback<Object>) con -> {
        StringRedisConnection str = (StringRedisConnection) con;
        for (int i = 1; i <= n; i++) {
            str.set("pipe:" + i, "1");
        }
        return null; // 一定要回 null,回覆 Spring 會自己收
    });
    return Map.of("n", n, "ms", System.currentTimeMillis() - start);
}

重新啟動 Java,各寫一萬筆(在主機的終端機下):

curl -X POST "localhost:8080/pipeline/one?n=10000"

curl -X POST "localhost:8080/pipeline/batch?n=10000"

https://ithelp.ithome.com.tw/upload/images/20261001/20184209af24d46bT0.png

https://ithelp.ithome.com.tw/upload/images/20261001/20184209RaLIJJ3MF6.png

一筆一筆送花了 1902 毫秒,Pipeline 只要 212 毫秒,快了 9 倍左右。

Redis 要做的事一樣是一萬次 SET,差在等待:一筆一筆送,每一筆都要等回覆才送下一筆;Pipeline 不等,一萬筆先全部送出去,回覆最後再一起收。之前灌資料用的 redis-cli --pipe 就是這個。

但 Pipeline 只是打包送,Redis 那邊還是一筆一筆執行,中間別人的指令照樣可以插進來。

MULTI

進 redis-cli,A 轉 30 給 B(在主機的終端機下):

docker exec -it redis30days redis-cli

MSET balance:A 100 balance:B 50

MULTI

DECRBY balance:A 30

INCRBY balance:B 30

EXEC

https://ithelp.ithome.com.tw/upload/images/20261001/20184209aulnk7KXRa.png

MULTI 之後的指令不會馬上執行,只回 QUEUED(排隊中),提示也多了 (TX),代表正在排隊。EXEC 才一口氣跑完,跑的時候中間不會插進別人的指令。

不會回滾

把 B 的餘額改成小數,再轉一次:

SET balance:B 80.5

MULTI

DECRBY balance:A 30

INCRBY balance:B 30

EXEC

MGET balance:A balance:B

https://ithelp.ithome.com.tw/upload/images/20261001/20184209svX8ktYVDJ.png

INCRBY 只能加整數,B 那筆噴錯。但 A 那筆照樣執行了,A 被扣了 30,B 沒加到,30 塊就這樣不見了。

MULTI 不會回滾,錯的那筆失敗,其他的照樣生效。

指令打錯字的話不一樣:

MULTI

DECRBY balance:A 30

INCRBYY balance:B 30

EXEC

GET balance:A

https://ithelp.ithome.com.tw/upload/images/20261001/20184209jRnl7q42DD.png

INCRBYY 排隊的時候就被擋下來,EXEC 回 EXECABORT,整批都不做,A 還是 40。

排隊的時候,Redis 只檢查指令名字跟參數個數,不會去看 key 裡面存了什麼:

  • INCRBYY 打錯字,排隊時就擋下來,整批不做
  • B 是小數,要等 EXEC 真的去加才發現,這時 A 已經扣了,只有 B 那筆失敗

WATCH

一樣在 redis-cli 裡,先盯著 A:

WATCH balance:A

GET balance:A

MULTI

DECRBY balance:A 30

先不要 EXEC,開另一個終端機,搶先扣 A 10 塊(在主機的終端機下):

docker exec redis30days redis-cli DECRBY balance:A 10

回到 redis-cli:

EXEC

GET balance:A

https://ithelp.ithome.com.tw/upload/images/20261001/201842096mpuSfQRRS.png

WATCH 之後 A 被別人改過,EXEC 回 (nil),整批不做,A 是別人扣完的 30。

所以可以先 GET 看餘額夠不夠,夠了再 MULTI 扣,中間被別人搶先改了就不做,重新讀一次再來。這種做法叫樂觀鎖:先不鎖,送出去的時候才檢查有沒有被改過。

Java 的 MULTI

exit 出來,新增一個 TransferController,一樣 A 轉給 B:

/** 包在 SessionCallback 裡,MULTI 到 EXEC 才會用同一條連線 */
@PostMapping
public List<Object> transfer(@RequestParam long amount) {
    return redis.execute(new SessionCallback<List<Object>>() {
        @Override
        @SuppressWarnings("unchecked")
        public List<Object> execute(RedisOperations ops) {
            ops.multi();
            ops.opsForValue().decrement("balance:A", amount); // 排隊,還沒執行
            ops.opsForValue().increment("balance:B", amount);
            return ops.exec(); // 一口氣執行,回每一筆的結果
        }
    });
}

/** 沒包 SessionCallback:每一行各拿各的連線 */
@PostMapping("/wrong")
public List<Object> wrong(@RequestParam long amount) {
    redis.multi();
    redis.opsForValue().decrement("balance:A", amount);
    redis.opsForValue().increment("balance:B", amount);
    return redis.exec();
}

重新啟動 Java,開另一個終端機,盯著 Redis 收到的指令(MONITOR 會印出收到的每個指令,在主機的終端機下):

docker exec redis30days redis-cli MONITOR | grep -E "MULTI|EXEC|DECRBY|INCRBY"

回到原本的終端機,餘額設回來,兩種寫法各轉一次(在主機的終端機下):

docker exec redis30days redis-cli MSET balance:A 100 balance:B 50

curl -X POST "localhost:8080/transfer?amount=30"

curl -X POST "localhost:8080/transfer/wrong?amount=30"

docker exec redis30days redis-cli MGET balance:A balance:B

https://ithelp.ithome.com.tw/upload/images/20261001/20184209oBgg4X9CU0.png

https://ithelp.ithome.com.tw/upload/images/20261001/20184209QAqvK9t0J7.png

https://ithelp.ithome.com.tw/upload/images/20261001/20184209TCrmpypHI6.png

/transfer 回 [70,80]。/wrong 回 500,但 MGET 一看,A 又少了 30、B 又多了 30,錢已經轉過去了。

看一下 MONITOR:

https://ithelp.ithome.com.tw/upload/images/20261001/20184209vQmQKXXKNt.png

中括號裡冒號後面的數字是連線的 port,每條連線都不一樣:

  • /transfer:四行都是同一條連線
  • /wrong:MULTI、DECRBY 跟 INCRBY、EXEC 各走一條。DECRBY 跟 INCRBY 那條沒下過 MULTI,直接就執行了;EXEC 那條也沒下過 MULTI,Java 的 console 噴 ERR EXEC without MULTI,所以回 500

Spring 的 multi() 一定要包在 SessionCallback 裡,不然看起來有排隊,其實是一行一行直接執行。

為什麼要在意

  • Redis 的事務不會回滾。 資料庫的交易是全部成功、或全部當作沒發生;Redis 的 MULTI 只保證中間不會被插隊,錯的那筆失敗,其他照樣寫進去。一定要對的東西,還是要交給資料庫。
  • Java 回 500,不代表什麼都沒寫。 就算有包 SessionCallback,B 是小數的時候打 /transfer 一樣回 500,但 A 的 30 已經扣了。看到錯誤就直接重試,A 就會被扣兩次。

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

明天

MULTI 排隊的時候指令還沒執行,拿不到值。要「先看夠不夠扣、夠才扣」只能搭 WATCH,被別人改過就得重來,人一多就一直重試。明天來看 Lua 腳本,判斷跟扣款在 Redis 裡一次做完/images/emoticon/emoticon12.gif


上一篇
Day 18|Cluster
下一篇
Day 20|Lua 腳本
系列文
從購物車到秒殺:30 天 Redis 高併發自學筆記 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言