[Redis] Redis and Redis Cluster integrate with Spring Boot

Redis 簡介

Redis是一種開源的,基於內存的資料結構儲存系統,也就是在RAM(隨機存取記憶體)中儲存和操作資料。RAM是一種揮發性記憶體,這意味著當電源關閉時,其中的資料會被清除。然而,由於RAM可以提供非常快速的讀寫速度,因此Redis可以提供高效能的資料存取。

這與傳統的基於磁碟的資料庫(如MySQL或PostgreSQL)有所不同,傳統的資料庫主要在磁碟上儲存資料,雖然磁碟的讀寫速度比RAM慢,但磁碟是非揮發性的,即使在電源關閉後,資料仍然可以保留。

資料結構

Redis支援多種資料結構,包括:

  • 字串(Strings):最基本的資料結構,可以儲存任何形式的字串,包括整數和浮點數。
  • 列表(Lists):一種有序的字串集合,可以在列表的頭部或尾部添加元素。
  • 集合(Sets):一種無序且不重複的字串集合。
  • 有序集合(Sorted sets):與集合類似,但每個元素都會關聯一個分數,Redis根據分數對元素進行排序。
  • 哈希(Hashes):一種字串對字串的映射,適合儲存物件。

然而後期也發展出了 Redis stack,也新增了一些資料結構,包括:

  • 串流(Streams):作用類似於僅附加記錄檔。串流有助於按事件發生的順序記錄事件,然後將其聯合處理。
  • 地理空間索引(Geospatial):可用於尋找特定地理半徑或邊界框內的位址。
  • 位元圖(Bitmaps):讓你可以在字串上執行位元運算。
  • 位元欄位(Redis bitfields):可以有效率地對字串值編碼多個計數器。位元欄位提供原子取得、設定和遞增運算,並支援不同的溢位政策。
  • HyperLogLog:資料結構提供大型集合基數(即元素數量)的機率估計。

雖然很重要,但本篇主要會把重點放在跟 Spring Boot 整合 Redis 的部分,如果想了解更多的話可以參考Redis英文官方文件,或是Redis中文官方文件。

持久化

Redis提供了兩種持久化方法:

  • RDB(Redis Database): 在指定的時間內,將資料集快照儲存到磁碟上。例如,可以設定每5分鐘進行一次快照儲存,將資料集儲存到磁碟上。RDB持久化是將Redis在記憶體中的資料集序列化到磁碟上的一個二進制文件中。
    • 優點:資料恢復快速。
    • 缺點:如果Redis意外關閉,可能會丟失最後一次快照之後的資料。例如,當設定為每5分鐘進行一次快照,當上一次快照結束後,又經過了4分鐘,還沒有進行下一次快照,此時Redis意外關閉,則這4分鐘內的資料將會丟失。
  • AOF(Append Only File):記錄從服務器接收到的每一條寫命令,並將這些命令記錄到磁碟上的日誌文件中。當Redis重新啟動時,可以通過重新執行這些命令來恢復資料集。
    • 優點:資料恢復更可靠。
    • 缺點:AOF日誌文件通常比RDB快照文件大,因此AOF持久化的恢復速度可能比RDB持久化慢。

應用場景

快取:由於Redis的高速讀寫性能,它常常被用作應用的快取層,來加速應用的響應速度。 任務隊列:Redis的列表和有序集合資料結構非常適合實現任務隊列。 發布/訂閱:Redis支援發布/訂閱模式,可以用於實現實時訊息系統。 計數器:Redis的INCR和DECR命令可以用來實現計數器功能。

使用 Docker 安装 Redis(單體)

Redis官方提供了一個Redis的Docker映像,可以通過Docker Hub下載。要安裝Redis,只需運行以下命令:

docker run --name my-redis -d -p 6379:6379 --name <your-redis-container-name> redis
  • 因為我不想使用 Redis 原生的 CLI,而且 Redis 後來來有推出一個可以用來檢視 Redis 的 GUI 工具,叫做 RedisInsight,可以參考RedisInsight。可以使用 Docker 安裝,非常方便。我們直接使用 Docker 安裝 RedisInsight:
docker run --name my-redis -d -p 6379:6379 --name <your-redis-container-name> redis
  • 我把以上的 redis 和 redisinsight 用 docker compose 包起來,這樣我直接在專案目錄底下使用 docker-compose up -d 就可以一次啟動兩個容器了。
version: '3.8'

services:
  redis:
    image: redis
    container_name: my-redis
    ports:
      - "6379:6379"

  redisinsight :
    image: redis/redisinsight:latest
    container_name: my-redisinsight
    ports:
      - "5540:5540"
    depends_on:
      - redis
  • 然後我們可以透過瀏覽器,輸入 http://localhost:5540 來進入 RedisInsight 的介面,使用上都非常直覺,我就不介紹囉。

不過要注意,如果你的 Redis 是使用 Docker 安裝,且 RedisInsight 也是使用 Docker 安裝在本機上,則 RedisInsight 連接 Redis 的時候,host 要填寫 host.docker.internal,而不是 localhost。主要是因為 Docker 的網路問題,我們就不在這邊詳細說明了。有興趣的話可以參考這篇文章或是這篇文章。

Spring boot 整合 Redis

我們就不廢話,直接來囉!

  • 我們的資料夾結構會如下:
src
├── main
│   ├── java
│   │   ├── com
│   │   │   ├── kai
│   │   │   │   ├── spring_boot_redis_practice
│   │   │   │   │   ├── config
│   │   │   │   │   │   └── SwaggerConfig.java
│   │   │   │   │   ├── MyController.java
│   │   │   │   │   └── MyService.java
│   │   │   │   └── SpringBootRedisPracticeApplication.java
│   │   └── resources
│   │       ├── application.properties
下略
  1. 先到 Spring Initializr 創建一個新的 Spring Boot 專案,
    • Project: Maven Project
    • Language: Java
    • Spring Boot: 3.3.0
    • Packaging: Jar
    • Java: 17
    • Dependencies 選擇 Spring Web 、Spring Data Redis、Spring Boot DevTools、Lombok,然後點擊 Generate 下載專案。
  2. 解壓縮下載的專案,然後使用 IntelliJ IDEA 或是 Eclipse 打開專案。
  3. 到 pom.xml 檔案中加入 swagger 的相關依賴,方便我們測試 API。
<!--swagger-->
<dependency>
   <groupId>org.springdoc</groupId>
   <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
   <version>2.0.2</version>
</dependency>
  1. 建立一個 Controller 類別,並且加上 @RestController 註解,並注入 MyService 類別。
@RestController
public class MyController {
    
        private final MyService myService;
    
        public MyController(MyService myService) {
            this.myService = myService;
        }
}
  1. 建立一個 'MyService' 類別,並且加上 @Service 註解,並注入 StringRedisTemplate 類別。
@Service
public class MyService {

    private final StringRedisTemplate stringRedisTemplate;

    public MyService(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }
}
  1. 開一個資料夾叫做 config,然後建立 SwaggerConfig 類別,並加上 @Configuration 註解。
@OpenAPIDefinition(
        info = @Info(
                title = "Spring Boot integration with single node Redis practice",
                version = "0.0"
        )
)
@Configuration
public class SwaggerConfig {
}
  1. 到 application.properties 檔案中加入 Redis 的連線設定。
spring.redis.host=localhost
spring.redis.port=6379
  1. 到瀏覽器輸入 localhost:5540 進入前面開好的 RedisInsight,方便我們觀察 Redis 的資料。
  2. 再到瀏覽器輸入 localhost:8080/swagger-ui.html 進入 Swagger 的介面,方便我們測試 API。

這樣所有基礎需求都寫完了,接下來我們就來實作一些 Redis 的基本操作吧~

為了方便,以下我們就都用 get Method 來操作 Redis。

Spring Data Redis 的基本操作

CRUD(用 Strings 舉例)

  • 新增一個字串到 Redis 中。
    • Controller
    @Operation(summary = "Save", description = "Save a key-value pair")
    @Tag(name = "Key-Value")
    @GetMapping("/save")
    public void save(@Parameter(description = "The key") String key, @Parameter(description = "The value") String value) {
        service.save(key, value);
    }
    • Service
    public void save(String key, String value) {
        stringRedisTemplate.opsForValue().set(key, value);
    }
  • 從 Redis 中取得一個字串。
    • Controller
    @Operation(summary = "Get", description = "Gets a value by key")
    @Tag(name = "Key-Value")
    @GetMapping("/get")
    public String get(@Parameter(description = "The key") String key) {
        return service.get(key);
    }
    • Service
    public String get(String key) {
        return stringRedisTemplate.opsForValue().get(key);
    }
  • 更新 Redis 中的一個字串。其實就是新增一個字串,只是 key 已經存在。所以跟新增一個字串的方法一樣。
    • Controller
    @Operation(summary = "Update", description = "Update a key-value pair")
    @Tag(name = "Key-Value")
    @GetMapping("/update")
    public void update(@Parameter(description = "The key") String key, @Parameter(description = "The value") String value) {
        service.update(key, value);
    }
    • Service
    public void update(String key, String value) {
        stringRedisTemplate.opsForValue().set(key, value);
    }
  • 刪除 Redis 中的一個字串。
    • Controller
    @Operation(summary = "Delete", description = "Deletes a key-value pair")
    @Tag(name = "Key-Value")
    @GetMapping("/delete")
    public void delete(String key) {
        service.delete(key);
    }
    • Service
    public void delete(String key) {
        stringRedisTemplate.delete(key);
    }
  • 是否存在 Redis 中的一個字串。
    • Controller
    @Operation(summary = "Exists", description = "Checks if a key exists")
    @Tag(name = "Key-Value")
    @GetMapping("/exists")
    public boolean exists(String key) {
        return service.exists(key);
    }
    • Service
    public boolean exists(String key) {
        return Boolean.TRUE.equals(stringRedisTemplate.hasKey(key));
    }
  • 儲存時,設定TTL(Time To Live),也就是 expire time。
    • Controller
    @Operation(summary = "Get Expire", description = "Gets the expiration time of a key")
    @Tag(name = "Key-Value")
    @GetMapping("/getExpire")
    public long getExpire(String key) {
        return service.getExpire(key);
    }
    • Service
    public void saveWithExpire(String key, String value, long seconds) {
        stringRedisTemplate.opsForValue().set(key, value, seconds, TimeUnit.SECONDS);
    }
  • 取得 TTL(Time To Live)。
    • Controller
     @Operation(summary = "Get Expire", description = "Gets the expiration time of a key")
    @Tag(name = "Key-Value")
    @GetMapping("/getExpire")
    public long getExpire(String key) {
        return service.getExpire(key);
    }
    • Service
    public long getExpire(String key) {
        Optional<Long> duration = Optional.ofNullable(stringRedisTemplate.getExpire(key)); // 因為 getExpire() 回傳時,可能已經過期了,所以用 Optional 來處理。
        return duration.orElse(0L); // 如果 duration 是 null(已經過期所以拿不到),則回傳 0。
    }
  • 如果找不到 key,則新增一個字串到 Redis 中。如果找的到 key,則不做任何事。
    • Controller
    @Operation(summary = "Save If Absent", description = "Save a key-value pair if the key does not exist")
    @Tag(name = "Key-Value")
    @GetMapping("/saveIfAbsent")
    public void saveIfAbsent(String key, String value) {
        service.saveIfAbsent(key, value);
    }
    • Service
    public void saveIfAbsent(String key, String value) {
        stringRedisTemplate.opsForValue().setIfAbsent(key, value);
    }
  • 計數器-增加(Increment)。
    • Controller
    @Operation(summary = "Increment", description = "Increments a key by a delta")
    @Tag(name = "Counter")
    @GetMapping("/increment")
    public void increment(String key, long delta) {
        service.increment(key, delta);
    }
    • Service
    public void increment(String key, long delta) {
        stringRedisTemplate.opsForValue().increment(key, delta);
    }
  • 計數器-減少(Decrement)。
    • Controller
    @Operation(summary = "Decrement", description = "Decrements a key by a delta")
    @Tag(name = "Counter")
    @GetMapping("/decrement")
    public void decrement(String key, long delta) {
        service.decrement(key, delta);
    }
    • Service
    public void decrement(String key, long delta) {
        stringRedisTemplate.opsForValue().decrement(key, delta);
    }
  • 把新的字串加到舊的字串後面,假設我原本的字串是 Hello,我想要加上 World,則結果就是 HelloWorld。
    • Controller
    @Operation(summary = "Append", description = "Appends a value to a key")
    @Tag(name = "String")
    @GetMapping("/append")
    public long append(String key, String value) {
        return service.append(key, value);
    }
    • Service
    public long append(String key, String value) {
        return stringRedisTemplate.opsForValue().append(key, value); // 可以由回傳值來得知新字串的長度。
    }
  • 使用 Range 取得字串的子字串。例如,我有一個字串, key 是 hw,value 是 HelloWorld,我想要取得 World,則可以使用 range("hw", 5, 10)。
    • Controller
    @Operation(summary = "Get Range", description = "Gets a range of values from a key")
    @Tag(name = "Other")
    @GetMapping("/getRange")
    public String getRange(String key, long start, long end) {
        return service.getRange(key, start, end);
    }
    • Service
    public String getRange(String key, long start, long end) {
        return stringRedisTemplate.opsForValue().get(key, start, end);
    }
  • 使用 Range 去取代字串中的子字串。例如,我有一個字串, key 是 hw,value 是 HelloWorld,我使用了setRange("hw", 0, "Hi")。則結果就是 HilloWorld。
    • Controller
    @Operation(summary = "Set Range", description = "Sets a range of values to a key")
    @Tag(name = "Other")
    @GetMapping("/setRange")
    public void setRange(String key, String value, long offset) {
        service.setRange(key, value, offset);
    }
    • Service
    public void setRange(String key, String value, long offset) {
        stringRedisTemplate.opsForValue().set(key, value, offset);
    }
  • 如果我想要一次取得多個 key 的 value
    • Controller
    @Operation(summary = "Multi Get", description = "Gets multiple values by key")
    @Tag(name = "Key-Value")
    @GetMapping("/multiGet")
    public List<String> multiGet(String key1, String key2) {
        return service.multiGet(key1, key2);
    }
    
    • Service
    public List<String> multiGet(String key1, String key2) {
        List<String> keys = new ArrayList<>();
        keys.add(key1);
        keys.add(key2);
        return stringRedisTemplate.opsForValue().multiGet(keys);
    }
  • 在物件導向的 Java 中,我們通常會使用物件來儲存資料,但 Redis 是一個 key-value 的資料庫,所以我們可以使用 Hash 來儲存物件。
    • Controller
    @Operation(summary = "Save Hash", description = "Save a hash")
    @Tag(name = "Hash")
    @GetMapping("/saveHash")
    public void saveHash(String key, String name, String description, Integer likes, Integer visitors) {
        service.saveHash(key, name, description, likes, visitors);
    }
    • Service
    public void saveHash(String key, String name, String description, Integer likes, Integer visitors) {
        Map<String, String> map = new HashMap<>();
        map.put("name", name);
        map.put("description", description);
        map.put("likes", likes.toString());
        map.put("visitors", visitors.toString());
        stringRedisTemplate.opsForHash().putAll(key, map);
    }
  • 取得 Hash 中的所有欄位。
    • Controller
    @Operation(summary = "Get hash", description = "Gets a hash by key")
    @Tag(name = "Hash")
    @GetMapping("/getHash")
    public Map<Object, Object> getHash(String key) {
        return service.getHash(key);
    }
    • Service
    public Map<Object, Object> getHash(String key) {
        return stringRedisTemplate.opsForHash().entries(key);
    }
  • 取得 Hash 中的某個欄位。
    • Controller
    @Operation(summary = "Get a field value from a hash", description = "Gets a field value from a hash")
    @Tag(name = "Hash")
    @GetMapping("/getHashFieldValue")
    public String getHashFieldValue(String key, String field) {
        return service.getHashValue(key, field);
    }
    • Service
    public String getHashValue(String key, String field) {
        return (String) stringRedisTemplate.opsForHash().get(key, field);
    }

列表(Lists)

  • 新增一個列表到 Redis 中。如果你有一個列表想要加到現有的列表後面,也一樣使用這個方法。
    • Controller
    @Operation(summary = "Save a list", description = "Save a list")
    @Tag(name = "List")
    @PostMapping("/saveList")
    public void saveList(String key, @RequestBody ArrayList<String> value) {
        service.saveList(key, value);
    }
    • Service
    public void saveList(String key, ArrayList<String> values) {
        stringRedisTemplate.opsForList().rightPushAll(key, values);
    }
  • 取得列表中的所有值。
    • Controller
    @Operation(summary = "Get a list", description = "Gets a list by key")
    @Tag(name = "List")
    @GetMapping("/getList")
    public List<String> getList(String key) {
        return service.getList(key);
    }
    • Service
    public List<String> getList(String key) {
        return stringRedisTemplate.opsForList().range(key, 0, -1);
    }
  • 新增一個值到列表的尾部
    • Controller
    @Operation(summary = "Add a value to the end of a list", description = "Add a value to the end of a list")
    @Tag(name = "List")
    @GetMapping("/addAValueToEndOfList")
    public void addAValueToEndOfList(String key, String value) {
        service.addAValueToEndOfList(key, value);
    }
    • Service
    public void addAValueToEndOfList(String key, String value) {
        stringRedisTemplate.opsForList().rightPush(key, value);
    }
  • 新增一個值到列表的頭部
    • Controller
    @Operation(summary = "Add a value to the beginning of a list", description = "Add a value to the beginning of a list")
    @Tag(name = "List")
    @GetMapping("/addAValueToBeginningOfList")
    public void addAValueToBeginningOfList(String key, String value) {
        service.addAValueToBeginningOfList(key, value);
    }
    • Service
    @Operation(summary = "Add a value to the beginning of a list", description = "Add a value to the beginning of a list")
    @Tag(name = "List")
    @GetMapping("/addAValueToBeginningOfList")
    public void addAValueToBeginningOfList(String key, String value) {
        service.addAValueToBeginningOfList(key, value);
    }
  • 從列表的尾部 pop 取得一個值,並且於列表中移除。
    • Controller
    @Operation(summary = "Pop a value from the end of a list", description = "Pop a value from the end of a list. After popping, the value is removed from the list.")
    @Tag(name = "List")
    @GetMapping("/popAValueFromEndOfList")
    public String popAValueFromEndOfList(String key) {
        return service.popAValueFromEndOfList(key);
    }
    • Service
    public String popAValueFromEndOfList(String key) {
        return stringRedisTemplate.opsForList().rightPop(key);
    }
  • 從列表的頭部 pop 取得一個值,並且於列表中移除。
    • Controller
    @Operation(summary = "Pop a value from the beginning of a list", description = "Pop a value from the beginning of a list. After popping, the value is removed from the list.")
    @Tag(name = "List")
    @GetMapping("/popAValueFromBeginningOfList")
    public String popAValueFromBeginningOfList(String key) {
        return service.popAValueFromBeginningOfList(key);
    }
    • Service
    public String popAValueFromBeginningOfList(String key) {
        return stringRedisTemplate.opsForList().leftPop(key);
    }
  • 取得某個 index 的值。
    • Controller
     @Operation(summary = "Get a value from a list by index", description = "Get a value from a list by index")
    @Tag(name = "List")
    @GetMapping("/getAValueFromListByIndex")
    public String getAValueFromListByIndex(String key, long index) {
        return service.getAValueFromListByIndex(key, index);
    }
    • Service
    public String getAValueFromListByIndex(String key, long index) {
        return stringRedisTemplate.opsForList().index(key, index);
    }
  • 以 index 從列表中移除某個值。
    • Controller
    @Operation(summary = "Remove a value from a list by index", description = "Remove a value from a list by index")
    @Tag(name = "List")
    @GetMapping("/removeAValueFromListByIndex")
    public void removeAValueFromListByIndex(String key, long index, String value) {
        service.removeAValueFromListByIndex(key, index, value);
    }
    • Service
    public void removeAValueFromListByIndex(String key, long index, String value) {
        stringRedisTemplate.opsForList().remove(key, index, value);
    }
  • 移除列表
    • Controller
    @Operation(summary = "Remove a list", description = "Remove a list")
    @Tag(name = "List")
    @GetMapping("/removeList")
    public void removeList(String key) {
        service.removeList(key);
    }
    • Service
    public void removeList(String key) {
        stringRedisTemplate.delete(key);
    }

集合(Sets)

  • 新增一個集合到 Redis 中。
    • Controller
    @Operation(summary = "Save a set", description = "Save a set")
    @Tag(name = "Set")
    @PostMapping("/saveSet")
    public void saveSet(String key, @RequestBody ArrayList<String> value) {
        service.saveSet(key, value);
    }
    • Service
    public void saveSet(String key, ArrayList<String> value) {
        stringRedisTemplate.opsForSet().add(key, value.toArray(new String[0]));
    }
  • 取得集合中的所有值。
    • Controller
    @Operation(summary = "Get a set", description = "Gets a set by key")
    @Tag(name = "Set")
    @GetMapping("/getSet")
    public List<String> getSet(String key) {
        return service.getSet(key);
    }
    • Service
    public List<String> getSet(String key) {
        return new ArrayList<>(Objects.requireNonNull(stringRedisTemplate.opsForSet().members(key)));
    }
  • 從集合中移除某個值。
    • Controller
    @Operation(summary = "Remove a value from a set", description = "Remove a value from a set")
    @Tag(name = "Set")
    @GetMapping("/removeAValueFromSet")
    public void removeAValueFromSet(String key, String value) {
        service.removeAValueFromSet(key, value);
    }
    • Service
    public void removeAValueFromSet(String key, String value) {
        stringRedisTemplate.opsForSet().remove(key, value);
    }
  • 比較兩個集合的差異。
    • Controller
     @Operation(summary = "Compare two sets and get the difference", description = "Compare two sets and get the difference")
    @Tag(name = "Set")
    @GetMapping("/difference")
    public Set<String> difference(String key1, String key2) {
        return service.difference(key1, key2);
    }
    • Service
    public Set<String> difference(String key1, String key2) {
        return stringRedisTemplate.opsForSet().
    }
  • Spring data redis 提供給 Set 的方法還有很多,例如 union、intersect、difference、isMember、size、members 等等,這邊就不一一列舉了。有用到再查就好囉!

有序集合(Sorted sets)- 暫時略過

哈希(Hashes)

  • 新增一個 hash 到 Redis 中。
    • Controller
    @Operation(summary = "Save Hash", description = "Save a hash")
    @Tag(name = "Hash")
    @GetMapping("/saveHash")
    public void saveHash(String key, String name, String description, Integer likes, Integer visitors) {
        service.saveHash(key, name, description, likes, visitors);
    }
    • Service
    public void saveHash(String key, String name, String description, Integer likes, Integer visitors) {
        Map<String, String> map = new HashMap<>();
        map.put("name", name);
        map.put("description", description);
        map.put("likes", likes.toString());
        map.put("visitors", visitors.toString());
        stringRedisTemplate.opsForHash().putAll(key, map);
    }
  • 取得 hash 中的所有欄位。
    • Controller
    @Operation(summary = "Get hash", description = "Gets a hash by key")
    @Tag(name = "Hash")
    @GetMapping("/getHash")
    public Map<Object, Object> getHash(String key) {
        return service.getHash(key);
    }
    • Service
    public Map<Object, Object> getHash(String key) {
        return stringRedisTemplate.opsForHash().entries(key);
    }
  • 取得 hash 中的某個欄位。
    • Controller
    @Operation(summary = "Get a field value from a hash", description = "Gets a field value from a hash")
    @Tag(name = "Hash")
    @GetMapping("/getHashFieldValue")
    public String getHashFieldValue(String key, String field) {
        return service.getHashValue(key, field);
    }
    • Service
    public String getHashValue(String key, String field) {
        return (String) stringRedisTemplate.opsForHash().get(key, field);
    }
  • 刪除 hash 中的某個欄位。
    • Controller
    @Operation(summary = "Delete a field from a hash", description = "Delete a field from a hash")
    @Tag(name = "Hash")
    @GetMapping("/deleteHashField")
    public void deleteHashField(String key, String field) {
        service.deleteHashField(key, field);
    }
    • Service
    public void deleteHashField(String key, String field) {
        stringRedisTemplate.opsForHash().delete(key, field);
    }
  • 判斷 hash 中是否存在某個欄位。
    • Controller
    @Operation(summary = "Exists a field in a hash", description = "Exists a field in a hash")
    @Tag(name = "Hash")
    @GetMapping("/existsHashField")
    public boolean existsHashField(String key, String field) {
        return service.existsHashField(key, field);
    }
    • Service
    public boolean existsHashField(String key, String field) {
        return stringRedisTemplate.opsForHash().hasKey(key, field);
    }
  • 刪除 hash。
    • Controller
    @Operation(summary = "Delete a hash", description = "Delete a hash")
    @Tag(name = "Hash")
    @GetMapping("/deleteHash")
    public void deleteHash(String key) {
        service.deleteHash(key);
    }
    • Service
    public void deleteHash(String key) {
        stringRedisTemplate.delete(key);
    }

串流(Streams)- 暫時略過

地理空間索引(Geospatial)- 暫時略過

位元圖(Bitmaps)- 暫時略過

位元欄位(Redis bitfields)- 暫時略過

HyperLogLog- 暫時略過

使用 Lua 脚本執行 Redis 命令

當我們在使用 Spring boot 整合 Redis 時,有時會需要執行一些複雜的 Redis 命令操作,如果我們都使用 Spring data Redis 提供的方法,會遇到幾個問題:

  • 需要多次進出 Redis,破壞了原子性。
  • 需要多次網路請求,增加了網路延遲與開銷。
  • 邏輯過於複雜,無法使用 Spring data Redis 提供的方法。 那麼,這時候我們可以使用 Lua 脚本來執行 Redis 命令,Lua 脚本可以保證原子性,並且可以減少網路請求。

那我們要怎麼在 Spring boot 中使用 Lua 脚本呢?我們可以使用 StringRedisTemplate 的 execute 方法來執行 Lua 脚本。

  1. 先新開一個 'LuaController' 類別,並且加上 @RestController 註解,並注入 LuaService 類別。
@RestController
public class LuaController {

    public final LuaService luaService;

    public LuaController(LuaService luaService) {
        this.luaService = luaService;
    }
    
}
  1. 新開一個 'LuaService' 類別,並且加上 @Service 註解,並注入 StringRedisTemplate 類別。
@Service
public class LuaService {

    private final StringRedisTemplate stringRedisTemplate;

    private final DefaultRedisScript<String> redisScript; 

    public LuaService(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
        redisScript = new DefaultRedisScript<>();
    }
}
  1. 在存放靜態資源的資料夾 resources 中,新增一個 Lua 脚本檔案,例如 test.lua。
  2. 在 test.lua 中寫入 Lua 脚本。
-- 設置一個鍵值對
redis.call('SET', 'mykey', 'myvalue')

-- 獲取鍵的值
local value = redis.call('GET', 'mykey')

-- 返回鍵的值
return value
  1. 在 LuaService 類別中新增一個方法,用來執行 Lua 脚本。
    public String executeLuaScript() {
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("test.lua")));
        redisScript.setResultType(String.class);
        return stringRedisTemplate.execute(redisScript, Collections.emptyList());
    }
  1. 在 LuaController 類別中新增一個方法,用來執行 Lua 脚本。
    @Operation(summary = "Execute Lua script", description = "Execute Lua script")
    @Tag(name = "Lua")
    @GetMapping("/executeLuaScript")
    public String executeLuaScript() {
        return luaService.executeLuaScript();
    }
  1. 最後我們就到我們的 Swagger 介面中,測試我們的 API 吧!觸發以後就會回傳 myvalue 囉!
  2. 也可以到我們的 RedisInsight 中,查看是否有成功設置 mykey 的值。

Redis 的 Pub/Sub 模式

Redis 的 Pub/Sub 模式是一種消息傳遞模式,相較於其他的消息傳遞工具而言,是屬於輕量、簡單的存在。就我個人的經歷而言,這只能用在一些簡單且不重要的訊息傳遞上,如果是一些重要的訊息傳遞,還是建議使用功能比較完整且安全的消息傳遞工具。例如 RabbitMQ、Kafka 等等。

我在後面提供的原始碼中有實作一個簡單的 Pub/Sub 模式,我就不多做解釋了。

另外我有看到這篇文章Redis Pub/Sub 是什麼、會造成什麼問題呢?,介紹 Redis 的 Pub/Sub 模式,寫得很棒~!有興趣的話可以看看。

另外在 Redisinsight 中,也有提供 Pub/Sub 的功能,可以用來觀察訊息的傳遞,不過你需要先去把它 subscribe,才能看到訊息的傳遞。否則即使你有使用 publish 來發送訊息,也是看不到的。

持久化

Redis 提供了兩種持久化的方式,分別是 RDB 和 AOF。 在一開始的時候我們大概介紹了,這邊我們就不多廢話了。

直接來看怎麼設定吧!

我們可以在 Redis 的官方網站上找到完整個 Redis config 設定。
跟大部分的 config 檔案一樣,我們只需要在 config 檔案中加入我們要的設定就好了。所以以下我們只會列出我們要的設定。

  1. 先在我們的專案底下開一個資料夾叫做 redis,然後在裡面新增一個檔案叫做 redis.conf。
  2. 在 redis.conf 中加入以下設定。

    這裡稍微注意一下,註解如果寫在你的設定後面,例如 save 30 1 # 這是一個註解,可能會導致錯誤,所以建議註解寫在設定上面。

# 啟用RDB持久化
# 快照的產生會覆蓋掉原本的快照,不會造成快照的累積
# 如果至少有 1 個 key 被修改,那在 900 秒內就要進行一次快照來保存這個修改
save 30 1
# 如果至少有 10 個 key 被修改,那在 300 秒內就要進行一次快照來保存這個修改
save 20 10
# 如果至少有 10000 個 key 被修改,那在 60 秒內就要進行一次快照來保存這個修改
save 10 10000

# 啟用AOF持久化
# 每秒同步一次,其他還有always, no
appendonly yes
appendfsync everysec

# 當 AOF 文件增長為上次重寫時的 100% 時,觸發 AOF 重寫,來維持 AOF 文件的大小,使文件不會一直不斷增長
auto-aof-rewrite-percentage 100

# 當 AOF 文件當小成長到 64MB 時,觸發 AOF 重寫
auto-aof-rewrite-min-size 64mb
  1. 接下來要去修改我們的 docker-compose.yml 檔案,把我們的 redis.confg 掛載到我們的 Redis 容器中,同時也要把我們在 Redis 容器中產生的持久化檔案(RDB 和 AOF)掛載到我們的本機中,這樣當我們的 Redis 容器被刪除時,我們的持久化檔案還是會存在。
version: '3.8'

services:
  redis:
    image: redis # 使用 Redis 官方的 image
    container_name: my-redis # 容器的名稱
    ports:
      - "6379:6379" # 將本機的 6379 port 對應到容器的 6379 port
    volumes:
      - ./redis/redis.conf:/usr/local/etc/redis/redis.conf # 將本機的 redis.conf 掛載到容器的 /usr/local/etc/redis/redis.conf
      - ./redis/redis-data:/data # 將容器的 /data 掛載到本機的 redis-data
    command: redis-server /usr/local/etc/redis/redis.conf # 啟動 Redis 時,使用我們的 redis.conf

  redisinsight:
    image: redis/redisinsight:latest
    container_name: my-redisinsight
    ports:
      - "5540:5540"
    depends_on:
      - redis
  1. 接下來就在我們的 docker-compose.yml 檔案所在的資料夾中,開啟終端機,輸入以下指令,啟動我們的 Redis 容器。這裡我們不使用 -d 參數,我們來觀察一下 Redis 的 log。
docker-compose up 
我們可以看到 Redis 的 log 中有以下訊息,啟動或讀取了我們的 RDB 及 AOF 檔案。
  1. 我們也可以發現這時在我們的 redis-data 資料夾中,多了三個檔案。當我們的 container 被刪除又重新啟動時,就會把這三個檔案掛載到我們的 Redis 容器中,這樣就可以保證我們的 Redis 持久化檔案不會遺失。
    • appendonly.aof.1.base.rdb,這是我們的 RDB 檔案。
    • appendonly.aof.1.incr.aof,這是我們的 AOF 檔案。
    • appendonly.aof.manifest,這是我們的 AOF manifest 檔案。
  2. 我們也可以使用已下指令到 redis 的容器中看看我們的持久化檔案。
docker exec -it my-redis /bin/bash
進入容器後,我們可以使用 `ls` 指令看看我們的持久化檔案。
ls /data/appendonlydir
我們可以看到上述的那三個檔案。
  1. 接下來我們可以使用前面寫好的那些 Redis 的操作,來存入一些資料,接著把 Redis 的容器刪除,然後再重新啟動 Redis 的容器,我們就可以看到我們的資料還在。

Redis Cluster

Redis Cluster 的結構

Redis Cluster 是一種分散式的架構。

graph TD
    subgraph Resis 集群
        direction LR
        主節點1[Master-主節點 1] --> 從節點1A[Slave-從節點 1A]
        主節點1 

        主節點2[Master-主節點 2] --> 從節點2A[Slave-從節點 2A]
        主節點2

        主節點3[Master-主節點 3] --> 從節點3A[Slave-從節點 3A]
        主節點3 
    end

    客戶端[客戶端] --> 主節點1
    客戶端 --> 主節點2
    客戶端 --> 主節點3

為什麼要使用 Redis Cluster

  • 單節點(single node/standalone)的 Redis 當資料量過大時,容量會遇到瓶頸,也無法擴展。而 Redis Cluster 是一個分散式的 Redis,可以將資料分散到多個節點上,這樣就可以擴展 Redis 的容量。
  • Redis Cluster 也提供了高可用性,當某個節點掛掉時,其他節點還是可以正常運作。
  • Redis Cluster 也提供了 Load Balancing,將請求分散到多個節點上,這樣就可以減少單一節點的負載。

Redis Cluster 是如何提供上述的功能呢?

  • Redis Cluster的數據分片(data sharding)機制
    • Redis Cluster 使用 hash slot (哈希槽) 來分片數據。
    • Redis Cluster 將所有的 key 分為 16384 個 hash slot,每個 key 通過 CRC16 計算出一個 16 位的整數,然後將這個整數對 16384 取模,來決定這個 key 屬於哪個 hash slot。
    • Redis Cluster 中的每個節點會負責一部分 hash slot,例如我有三個節點,那麼它們負責的 hash slot狀況可能就如下:
      • 節點1: 0-5500
      • 節點2: 5501-11000
      • 節點3: 11001-16383
    • 我們後面整合 spring data redis 時,啟動專案就會出現像下面這樣,顯示所有的 slots 都被 cover分配好了的 log 訊息
    • 當一個 key-value 被存入 Redis Cluster 時,Redis Cluster 會根據 key 計算出它屬於哪個 hash slot,然後將這個 key-value 存入負責這個 hash slot 的節點中。
    • 當我們要取得一個 key-value 時,Redis Cluster 會根據 key 計算出它屬於哪個 hash slot,然後去負責這個 hash slot 的節點中取得這個 key-value。
    • 自動重新分片: 然而這些 has slot 的分配是動態的,當我們的節點出現 新增、故障、下線 時,Redis Cluster 會重新分配這些 hash slot。這就保證了 Redis Cluster 的高可用性。
    • Redis Cluster 是使用異步複製的方式來實現數據的同步,當一個 key-value 被存入 Redis Cluster 時,Redis Cluster 會將這個 key-value 存入主節點,然後主節點會將這個 key-value 複製到從節點中,這樣就保證了數據的一致性。但並不是強一致性。
  • 自動故障轉移(Automatic Failover):當一個主節點掛掉時,Redis Cluster 會自動選擇一個從節點升級為主節點,這樣就保證了 Redis Cluster 的高可用性。
  • Gossip 協議:Redis Cluster 使用 Gossip 協議來實現節點之間的通信,保證各節點之間的狀態一致性。
  • 主從架構
    • Redis Cluster 中的每個主節點(Master)可以有一個或多個從節點(Slave)。
    • 主節點(Master)負責處理讀寫請求,從節點(Slave)負責備份與故障轉移。當主節點掛掉時,Redis Cluster 會自動選擇一個從節點升級為主節點。
    • 從節點的複製是異步的,不會阻塞主節點的寫入。
    • 從節點的複製是單向的,從節點不會對主節點進行寫入。
    • 故障檢測
      • Cluster 中的節點通過 Goosip 協議來進行通信,每個節點都會定期向其他節點發送 ping 請求,如果一個主節點在一段時間內沒有收到其他節點的 pong 回應,那麼這個節點就會被標記為 "疑似下線 PFAIL"。
      • 當超過半數的主節點都認為某個疑似下線的節點是下線的時候,這個節點就會被標記為 "下線 FAIL"。
      • 當一個主節點被標記為 "下線 FAIL" 時,Redis Cluster 會把該主節點的從節點升級為主節點,這樣就保證了 Redis Cluster 的高可用性。
      • 新的主節點會接管原來主節點的 hash slot。

大概介紹到這邊就好,不然就太複雜了,我也沒完全搞懂~ 如果有興趣的話,可以到 Redis 的官方網站上看看更詳細的介紹。

使用 Docker 安裝 Redis Cluster

  • 先開一個叫做 .env 的檔案,並且加入以下設定。當使用 docker compose up 時,Docker 會讀取這個檔案,並且將這些設定注入到我們的 docker-compose.yml 檔案中。這裡的 ip 請使用你正在使用的 網路 IPv4 位址。你可以使用 ipconfig 或 ifconfig 來查看你的 IPv4 位址。
ip=<your ip>
  • docker-compose.yml
    • redis/redisinsight:latest:
      • RedisInsight 是一個 Redis 的 GUI 工具,可以用來查看 Redis 的資料、設定、監控等等。
    • 為什麼除了 Redis 的 Port 外,還要多開一個加 10000 的 Port?
      • 每個 Redis Cluster 節點需要兩個開放的 TCP 連接。一個用於服務客戶端的 Redis TCP 端口,例如 7001,另一個稱為集群總線端口。默認情況下,集群總線端口是通過在數據端口上加 10000 設置的(例如 17001)。相關內容可以參考官方文件
      • 集群總線是一個節點間的通信通道,使用二進制協議進行通信,這種協議更適合節點間的信息交換,因為它佔用的帶寬和處理時間較少。節點使用集群總線進行故障檢測、配置更新、故障轉移授權等操作。客戶端不應該嘗試與集群總線端口通信,而應使用 Redis 命令端口。然而,請確保在防火牆中開放這兩個端口,否則 Redis 集群節點之間將無法通信。
      • 為了讓 Redis Cluster 正常工作:
        • 集群總線端口必須能夠被所有其他集群節點訪問
        • 客戶端通信端口(通常為 6379),用於與客戶端通信,並且應對所有需要訪問集群的客戶端開放,以及所有使用該端口進行鍵遷移的其他集群節點開放。
    • Docker 使用 NAT(Network Address Translation) 來實現容器與外部網路的通信,因此在容器中無法直接訪問外部網路。因此,我們需要將容器的端口映射到主機的端口,這樣我們才能通過主機的端口訪問容器的端口。
    • 然而 Redis Cluster 目前並不支援 NAT,也就是無法使用 IP 地址或 TCP 端口重新映射的環境。
    • 如果要使 Docker 與 Redis Cluster 兼容,需要使用 Docker 主機網絡模式。可以參考這裡。
    • context: redis:
      • 指定 Dockerfile 的路徑,這樣 Docker 就會到 redis 資料夾中找 Dockerfile。Dockerfile 的內容後續再提。
    • entrypoint: [redis-server, /etc/redis/rediscluster.conf, --port,"7001", --cluster-announce-ip,"${ip}"]:
      • 指定容器啟動時執行的命令,這裡我們使用 redis-server 啟動 Redis,
      • 指定配置文件為 /etc/redis/rediscluster.conf,並且指定端口為 7001,
      • 並且指定集群通知的 IP 為 ${ip}。前述的 ${ip} 是我們在 .env 檔案中設定的 IP。因為前面提到的 Redis Cluster 並不支援 NAT,所以我們需要指定 IP。而這裡的 IP 是使用我正在使用的 WIFI IPv4 位址。相關內容我其實也沒有完全搞懂,可以參考[官方文件](IPv4 位址)。
    • entrypoint: [/bin/sh,-c,'echo "yes" | redis-cli -a pass.123 --cluster create ${ip}:7001 ${ip}:7002 ${ip}:7003 ${ip}:7004 ${ip}:7005 ${ip}:7006 --cluster-replicas 1']
      • 指定容器啟動時執行的命令,這裡我們使用 /bin/sh 啟動 shell,
      • -c 參數表示後面的內容是一個命令,
      • redis-cli 創建 Redis Cluster
      • ${ip}:7001 ${ip}:7002 ${ip}:7003 ${ip}:7004 ${ip}:7005 ${ip}:7006 是我們的 Redis Cluster 的節點,
      • --cluster-replicas 1 表示每個主節點有一個從節點。
    • depends_on:
      • 指定容器啟動時依賴的容器,意思是當我們的 redis-cluster-creator 容器啟動時,會等到 redis-node1、redis-node2、redis-node3、redis-node4、redis-node5、redis-node6 容器啟動後才會啟動。
version: '3.4'

services:
  redisinsight :
    image: redis/redisinsight:latest 
    container_name: my-redisinsight
    ports:
      - "5540:5540"
  redis-node1:
    build:
      context: redis
    ports:
      - "7001:7001" 
      - "17001:17001"
    restart: always
    entrypoint: [redis-server, /etc/redis/rediscluster.conf, --port,"7001", --cluster-announce-ip,"${ip}"]
    volumes:
      - ./logs/node1:/root/redis/log
  redis-node2:
    build:
      context: redis
    ports:
      - "7002:7002"
      - "17002:17002"
    restart: always
    entrypoint: [redis-server, /etc/redis/rediscluster.conf,--port,"7002",--cluster-announce-ip,"${ip}"]
    volumes:
      - ./logs/node2:/root/redis/log
  redis-node3:
    build:
      context: redis
    ports:
      - "7003:7003"
      - "17003:17003"
    restart: always
    entrypoint: [redis-server, /etc/redis/rediscluster.conf,--port,"7003",--cluster-announce-ip,"${ip}"]
    volumes:
      - ./logs/node3:/root/redis/log
  redis-node4:
    build:
      context: redis
    ports:
      - "7004:7004"
      - "17004:17004"
    restart: always
    entrypoint: [redis-server, /etc/redis/rediscluster.conf,--port,"7004",--cluster-announce-ip,"${ip}"]
    volumes:
      - ./logs/node4:/root/redis/log
  redis-node5:
    build:
      context: redis
    ports:
      - "7005:7005"
      - "17005:17005"
    restart: always
    entrypoint: [redis-server, /etc/redis/rediscluster.conf,--port,"7005",--cluster-announce-ip,"${ip}"]
    volumes:
      - ./logs/node5:/root/redis/log
  redis-node6:
    build:
      context: redis
    ports:
      - "7006:7006"
      - "17006:17006"
    restart: always
    entrypoint: [redis-server, /etc/redis/rediscluster.conf,--port,"7006",--cluster-announce-ip,"${ip}"]
    volumes:
      - ./logs/node6:/root/redis/log

  redis-cluster-creator:
    image: redis:6.0.3
    entrypoint: [/bin/sh,-c,'echo "yes" | redis-cli -a pass.123 --cluster create ${ip}:7001 ${ip}:7002 ${ip}:7003 ${ip}:7004 ${ip}:7005 ${ip}:7006 --cluster-replicas 1']
    depends_on:
      - redis-node1
      - redis-node2
      - redis-node3
      - redis-node4
      - redis-node5
      - redis-node6
  • Dockerfile
    • 使用 redis:6.0.3 作為基礎 image。
    • 將 rediscluster.conf 複製到 /etc/redis/rediscluster.conf。
    • 使用 redis-server /etc/redis/rediscluster.conf 啟動 Redis Cluster。
FROM redis:6.0.3
COPY rediscluster.conf /etc/redis/rediscluster.conf
ENTRYPOINT redis-server /etc/redis/rediscluster.conf
  • rediscluster.conf
    • bind:
      • 指定 Redis Cluster 的 IP。0.0.0.0表示所有的 IP 都可以訪問我們的 Redis Cluster。
    • cluster-enabled yes:
      • 啟用 Redis Cluster。
    • cluster-config-file nodes.conf:
      • 指定 cluster config 檔案。
    • cluster-node-timeout 5000:
      • 設置了節點失效的超時時間,單位是毫秒。如果一個節點在 5000 毫秒(5 秒)內沒有響應,其他節點將認為它已經失效。這個設置對於集群中的故障檢測和故障轉移非常重要。
    • masterauth pass.123:
      • 這個指令是用來設定從結點連接到主節點時需要提供的密碼。
    • requirepass pass.123:
      • 設置客戶端連接到 Redis 伺服器時需要提供的密碼。
    • logfile "/root/redis/log/redis.log":
      • 指定了 Redis 的 log 檔案。
bind 0.0.0.0
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
masterauth pass.123
requirepass pass.123
logfile "/root/redis/log/redis.log"
  • 此時的資料夾結構如下:
.
├── .env
├── docker-compose.yml
└── redis
    ├── Dockerfile
    ├── rediscluster.conf
    └── redis-data
  • 使用 docker-compose 啟動 Redis Cluster
docker-compose up
  • 當你在 console 中看到以下訊息時,表示 Redis Cluster 啟動成功。
spring-boot-redis-cluster-practice-redis-cluster-creator-1  | [OK] All nodes agree about slots configuration.
spring-boot-redis-cluster-practice-redis-cluster-creator-1  | >>> Check for open slots...
spring-boot-redis-cluster-practice-redis-cluster-creator-1  | >>> Check slots coverage...
spring-boot-redis-cluster-practice-redis-cluster-creator-1  | [OK] All 16384 slots covered.
spring-boot-redis-cluster-practice-redis-cluster-creator-1 exited with code 0

Spring boot 整合 Redis Cluster

  • 我們的資料夾結構會如下
.
├── redis
│   ├── Dockerfile
│   └── rediscluster.conf
├── src
│   ├── main
│   │   ├── java
│   │   │   └── com
│   │   │       └── kai.spring_boot_redis_cluster_practice
│   │   │           ├── config
│   │   │           │   └── WriteToMasterReadFromReplicaConfig.java
|   |   |           │   └── SwaggerConfig.java
│   │   │           ├── controller
│   │   │           │   ├── MyController.java
│   │   │           │   └── TestController.java
│   │   │           ├── service
│   │   │           │   ├── MyService.java
│   │   │           │   └── TestService.java
│   │   │           └── SpringBootRedisClusterPracticeApplication.java
│   │   └── resources
│   │       ├── application.yml
│   │       ├── redis-cluster-connection-info.properties
│   │       └── static
│   └── test
├── .env
├── docker-compose.yml   
  1. 先到 Spring Initializr 創建一個新的 Spring Boot 專案,
    • Project: Maven Project
    • Language: Java
    • Spring Boot: 3.3.0
    • Packaging: Jar
    • Java: 17
  2. 加入以下的 dependencies。
    • lombok
    • devtools
    • spring-boot-starter-data-redis
    • spring-boot-starter-web
  3. 另外自己加入方便測試的 swagger
<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.0.2</version>
</dependency>
  1. spring boot 的 application.yml。 IP 就使用跟前述的 .env 檔案中一樣的 IP。
spring:
  application:
    name: spring-boot-redis-cluster-practice
  config:
    import: classpath:ip.properties
  data:
    redis:
      cluster:
        nodes:
          - ${ip}:7000
          - ${ip}:7001
          - ${ip}:7002
          - ${ip}:7003
          - ${ip}:7004
          - ${ip}:7005
      password: ${redis.password}
  1. 另外開一個 redis-cluster-connection-info.properties 檔案,並且加入以下設定。
ip=192.168.60.62
redis.password=pass.123
  1. SwaggerConfig.java
@OpenAPIDefinition(
        info = @Info(
                title = "Spring Boot integration with Redis Cluster practice",
                version = "0.0"
        )
)
@Configuration
public class SwaggerConfig {
}
  1. MyController.java
@RestController
public class MyController {

    private final MyService service;

    public MyController(MyService service) {
        this.service = service;
    }

    @Operation(
            summary = "Hello World",
            description = "Returns a simple Hello World message"
    )
    @Tag(name = "Hello World")
    @GetMapping("/")
    public String helloWorld() {
        return "Hello World!";
    }

    @Operation(
            summary = "Save a Base-operation pair",
            description = "Save a Base-operation pair"
    )
    @Tag(name = "Base-operation")
    @GetMapping("/save")
    public void save(@Parameter(description = "The key") String key, @Parameter(description = "The value") String value) {
        service.save(key, value);
    }

    @Operation(
            summary = "Get a value by key",
            description = "Gets a value by key"
    )
    @Tag(name = "Base-operation")
    @GetMapping("/get")
    public String get(@Parameter(description = "The key") String key) {
        return service.get(key);
    }

    @Tag(name = "Base-operation")
    @Operation(
            summary = "Get all the keys and values in Redis Cluster",
            description = "Gets all the keys and values in Redis Cluster"
    )
    @GetMapping("/getAll")
    public List<String> getAll() {
        return service.getAll();
    }

    @Tag(name = "Base-operation")
    @Operation(
            summary = "Flush all the data in Redis Cluster",
            description = "Deletes all the data in Redis Cluster"
    )
    @Tag(name = "Base-operation")
    @GetMapping("/flush")
    public String flush() {
        return service.flush();
    }
}
  1. MyService.java
@Service
public class MyService {

    private static final Logger log = LoggerFactory.getLogger(MyService.class);
    private final StringRedisTemplate stringRedisTemplate;

    public MyService(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }


    public void save(String key, String value) {

        stringRedisTemplate.opsForValue().set(key, value);

        log.info("Saved Key: {}, Value: {}", key, value);
    }

    public String get(String key) {
        String value = stringRedisTemplate.opsForValue().get(key);

        log.info("Key: {}, Value: {}", key, value);

        return value;
    }

    public String flush() {
        stringRedisTemplate.getConnectionFactory().getConnection().flushAll();
        log.info("Flushed all keys");
        return "Flushed all keys";
    }

    public List<String> getAll() {
        List<String> result = new ArrayList<>();
        stringRedisTemplate.keys("*").forEach(key -> {
            String keyWithValues = key + ": " + stringRedisTemplate.opsForValue().get(key);
            result.add(keyWithValues);
        });

        log.info("All keys and values: {}", result);

        return result;
    }
}
  1. Redis Cluster 其實在一般的 Redis 操作上沒有太大的差異,但提供了 數據分片(Data sharding)、自動故障轉移(Automatic Failover)、高可用性(Availability)、Load Balancing 等功能。這裡我們就寫一些用來測試數據分片、自動故障轉移、高可用性的 API。程式碼我認為寫得很清楚了,所以我就不過多解釋,有興趣的到最後面把我的程式碼下載下來看看吧。
  2. 新加 redis 的 WriteToMasterReadFromReplicaConfig 來設定 redis cluster 的讀寫分離
@Configuration
public class WriteToMasterReadFromReplicaConfig {

    @Value("${ip}")
    private String ip;

    @Value("${redis.password}")
    private String redisPassword;

    @Bean
    public LettuceConnectionFactory redisConnectionFactory() {

        LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
                .readFrom(REPLICA_PREFERRED) // 設定讀寫分離
                .build();

        RedisClusterConfiguration serverConfig = new RedisClusterConfiguration();
        serverConfig.clusterNode(ip, 7001);
        serverConfig.clusterNode(ip, 7002);
        serverConfig.clusterNode(ip, 7003);
        serverConfig.clusterNode(ip, 7004);
        serverConfig.clusterNode(ip, 7005);
        serverConfig.clusterNode(ip, 7006);
        serverConfig.setPassword(redisPassword);

        return new LettuceConnectionFactory(serverConfig, clientConfig);
    }

}
  1. TestController.java
@RestController
public class TestController {

    private final TestService testService;

    public TestController(TestService testService) {
        this.testService = testService;
    }

    @Operation(
            summary = "Test sharding",
            description = """
            數據分片 (Data Sharding)
            
            Redis Cluster 使用 16384 個哈希槽來分配數據。我們可以測試數據是否確實分佈在不同的節點上。
            
            測試案例:
            
            寫入大量數據
            檢查每個節點的數據分佈
            """)
    @GetMapping("/test-sharding")
    public Map<String, Long> testSharding(@Parameter(description = "The number of keys to write") @RequestParam(defaultValue = "1000") int keyCount) {
        return testService.testSharding(keyCount);
    }
//
    @Operation(
            summary = "Test availability、Automatic Failover Step 1",
            description = """
            高可用性 (High Availability),自動故障轉移 (Automatic Failover)

            Redis Cluster 通過主從複制確保高可用性。我們可以測試當一個主節點故障時,系統是否仍然可用。

            測試案例:

            寫入數據
            手動關閉一個主節點
            嘗試讀取數據
            """)
    @GetMapping("/test-availability")
    public String test() {
        return testService.testAvailability();
    }

    @Operation(
            summary = "Test availability、Automatic Failover Step 2",
            description = """
            手動關閉某一個主節點後,重啟一下spring boot,再次嘗試讀取數據,會發現數據依然可以讀取,且由另外一個主節點提供服務。
            至於為什麼要重啟 spring boot 才能看到效果,我不是很確定。
            可能是因為一開始連線的時候,確定好有哪些可用節點後,分配好了槽,所以即使後來有節點掛掉,他還是依照一開始的分配好的節點來回覆,
            而重啟 spring boot 可能是重新連線,重新分配槽,所以才能看到效果。但實際上就算你不重啟還是可以取得資料,只是他仍然顯示資料是從舊的節點取回。
            測試完畢後,如果你想要重新啟動節點,請把剛剛關掉的節點啟動,然後再重新跑一次 redis-cluster-creator container。
            """)
    @GetMapping("/test-availability-2")
    public String testAvailability2() {
        return testService.testAvailability2();
    }

    @Operation(
            summary = "Test read/write splitting",
            description = """
            讀寫分離 (Read/Write Splitting)

            Redis Cluster 支持從主節點寫入數據,從從節點讀取數據,這可以提高讀取性能。

            測試案例:
            一次寫操作和多次讀操作,並記錄每次操作使用的節點。
            獲取當前操作使用的 Redis 節點的信息,包括節點 ID 和角色(主節點或副本節點)。
            """)
    @GetMapping("/test-read-write-splitting")
    public Map<String, String> testReadWriteSplitting(@RequestParam String key, @RequestParam String value, @RequestParam(defaultValue = "10") int readCount) {
//        TODO
        return null;
    }
}
  1. TestService.java
@Service
public class TestService {

    private static final Logger logger = LoggerFactory.getLogger(TestService.class);
    private final StringRedisTemplate redisTemplate;

    public TestService(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public Map<String, Long> testSharding(Integer keyCount) {

        for (int i = 0; i < keyCount; i++) {
            redisTemplate.opsForValue().set("key" + i, "value" + i);
        }

        Map<String, Long> nodePortAndKeyCounts = new HashMap<>();
        for (int i = 0; i < 1000; i++) {
            String key = "key" + i;
            RedisClusterConnection clusterConnection = redisTemplate.getConnectionFactory().getClusterConnection();
            int slot = clusterConnection.clusterGetSlotForKey(key.getBytes()); // Redis cluster 會根據 key 的哈希值來分配槽
            String nodePort = String.valueOf(clusterConnection.clusterGetNodeForSlot(slot).getPort()); // 根據槽來獲取節點
            nodePortAndKeyCounts.put(nodePort, nodePortAndKeyCounts.getOrDefault(nodePort, 0L) + 1);
        }

        logger.info("""
                Node ports and their key counts:
                {}
                """, nodePortAndKeyCounts);

        return nodePortAndKeyCounts;

    }

    public String testAvailability() {

//    1. 先寫入一筆數據
        String key = "availabilityKey";
        String value = "availabilityValue";
        redisTemplate.opsForValue().set(key, value);

        logger.info("Written key: {}, value: {}", key, value);

//    2. 確認他在哪個節點
        RedisClusterConnection clusterConnection = redisTemplate.getConnectionFactory().getClusterConnection();
        int slot = clusterConnection.clusterGetSlotForKey(key.getBytes());
        String nodePort = String.valueOf(clusterConnection.clusterGetNodeForSlot(slot).getPort());

        logger.info("The key is stored in the node with port: {}", nodePort);

        return "The key is stored in the node with port: " + nodePort + ", Please manually shut down the node by using docker stop command. Then reboot the app and use the testAvailability2 API.";
    }

    public String testAvailability2() {
        String key = "availabilityKey";

        String retrievedValue = redisTemplate.opsForValue().get(key);

        RedisClusterConnection clusterConnection = redisTemplate.getConnectionFactory().getClusterConnection();
        int slot = clusterConnection.clusterGetSlotForKey(key.getBytes());
        RedisClusterNode node = clusterConnection.clusterGetNodeForSlot(slot);
        int nodePort = node.getPort();

        logger.info("The key is stored in the node with port: {}, value: {}", nodePort, retrievedValue);

        return "The key is stored in the node with port: " + nodePort + ", value: " + retrievedValue;

    }
}
  • 啟動 Spring boot 專案,並且到 Swagger 介面中測試我們的 API 吧~

Redis 的分布式鎖 TODO

新的 Redis stack 搭配 Redis OM

Redis OM 目前還是 Beta 版,所以不建議使用在專案上。不過它確實提供了很多方便的方法,就如同 Spring data JPA 差不多。

有興趣的話,可以考我的另一篇文章Skeleton of Spring Boot + Redis OM + Redis Stack

總結

Reference