# [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英文官方文件](https://redis.io/docs/latest/develop/data-types/),或是[Redis中文官方文件](https://redis.dev.org.tw/docs/data-types/)。 ### 持久化 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,只需運行以下命令: ```bash docker run --name my-redis -d -p 6379:6379 --name <your-redis-container-name> redis ``` - 因為我不想使用 Redis 原生的 CLI,而且 Redis 後來來有推出一個可以用來檢視 Redis 的 GUI 工具,叫做 RedisInsight,可以參考[RedisInsight](https://redis.io/docs/latest/operate/redisinsight/install/)。可以使用 Docker 安裝,非常方便。我們直接使用 Docker 安裝 RedisInsight: ```bash docker run --name my-redis -d -p 6379:6379 --name <your-redis-container-name> redis ``` - 我把以上的 redis 和 redisinsight 用 docker compose 包起來,這樣我直接在專案目錄底下使用 `docker-compose up -d` 就可以一次啟動兩個容器了。 ```yaml 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 的網路問題,我們就不在這邊詳細說明了。有興趣的話可以參考[這篇文章](https://docs.docker.com/desktop/networking/#use-cases-and-workarounds)或是[這篇文章](https://docs.docker.com/network/)。 ## 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](https://start.spring.io/) 創建一個新的 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。 ```xml <!--swagger--> <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.0.2</version> </dependency> ``` 4. 建立一個 `Controller` 類別,並且加上 `@RestController` 註解,並注入 `MyService` 類別。 ```java @RestController public class MyController { private final MyService myService; public MyController(MyService myService) { this.myService = myService; } } ``` 5. 建立一個 'MyService' 類別,並且加上 `@Service` 註解,並注入 `StringRedisTemplate` 類別。 ```java @Service public class MyService { private final StringRedisTemplate stringRedisTemplate; public MyService(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; } } ``` 6. 開一個資料夾叫做 `config`,然後建立 `SwaggerConfig` 類別,並加上 `@Configuration` 註解。 ```java @OpenAPIDefinition( info = @Info( title = "Spring Boot integration with single node Redis practice", version = "0.0" ) ) @Configuration public class SwaggerConfig { } ``` 7. 到 `application.properties` 檔案中加入 Redis 的連線設定。 ```properties spring.redis.host=localhost spring.redis.port=6379 ``` 8. 到瀏覽器輸入 `localhost:5540` 進入前面開好的 RedisInsight,方便我們觀察 Redis 的資料。 9. 再到瀏覽器輸入 `localhost:8080/swagger-ui.html` 進入 Swagger 的介面,方便我們測試 API。 這樣所有基礎需求都寫完了,接下來我們就來實作一些 Redis 的基本操作吧~ > 為了方便,以下我們就都用 `get` Method 來操作 Redis。 ## Spring Data Redis 的基本操作 ### CRUD(用 Strings 舉例) - 新增一個字串到 Redis 中。 - Controller ```java @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 ```java public void save(String key, String value) { stringRedisTemplate.opsForValue().set(key, value); } ``` - 從 Redis 中取得一個字串。 - Controller ```java @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 ```java public String get(String key) { return stringRedisTemplate.opsForValue().get(key); } ``` - 更新 Redis 中的一個字串。其實就是新增一個字串,只是 key 已經存在。所以跟新增一個字串的方法一樣。 - Controller ```java @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 ```java public void update(String key, String value) { stringRedisTemplate.opsForValue().set(key, value); } ``` - 刪除 Redis 中的一個字串。 - Controller ```java @Operation(summary = "Delete", description = "Deletes a key-value pair") @Tag(name = "Key-Value") @GetMapping("/delete") public void delete(String key) { service.delete(key); } ``` - Service ```java public void delete(String key) { stringRedisTemplate.delete(key); } ``` - 是否存在 Redis 中的一個字串。 - Controller ```java @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 ```java public boolean exists(String key) { return Boolean.TRUE.equals(stringRedisTemplate.hasKey(key)); } ``` - 儲存時,設定TTL(Time To Live),也就是 expire time。 - Controller ```java @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 ```java public void saveWithExpire(String key, String value, long seconds) { stringRedisTemplate.opsForValue().set(key, value, seconds, TimeUnit.SECONDS); } ``` - 取得 TTL(Time To Live)。 - Controller ```java @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 ```java 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 ```java @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 ```java public void saveIfAbsent(String key, String value) { stringRedisTemplate.opsForValue().setIfAbsent(key, value); } ``` - 計數器-增加(Increment)。 - Controller ```java @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 ```java public void increment(String key, long delta) { stringRedisTemplate.opsForValue().increment(key, delta); } ``` - 計數器-減少(Decrement)。 - Controller ```java @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 ```java public void decrement(String key, long delta) { stringRedisTemplate.opsForValue().decrement(key, delta); } ``` - 把新的字串加到舊的字串後面,假設我原本的字串是 `Hello`,我想要加上 `World`,則結果就是 `HelloWorld`。 - Controller ```java @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 ```java public long append(String key, String value) { return stringRedisTemplate.opsForValue().append(key, value); // 可以由回傳值來得知新字串的長度。 } ``` - 使用 Range 取得字串的子字串。例如,我有一個字串, key 是 `hw`,value 是 `HelloWorld`,我想要取得 `World`,則可以使用 `range("hw", 5, 10)`。 - Controller ```java @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 ```java 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 ```java @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 ```java public void setRange(String key, String value, long offset) { stringRedisTemplate.opsForValue().set(key, value, offset); } ``` - 如果我想要一次取得多個 key 的 value - Controller ```java @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 ```java 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 ```java @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 ```java 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 ```java @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 ```java public Map<Object, Object> getHash(String key) { return stringRedisTemplate.opsForHash().entries(key); } ``` - 取得 Hash 中的某個欄位。 - Controller ```java @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 ```java public String getHashValue(String key, String field) { return (String) stringRedisTemplate.opsForHash().get(key, field); } ``` ### 列表(Lists) - 新增一個列表到 Redis 中。如果你有一個列表想要加到現有的列表後面,也一樣使用這個方法。 - Controller ```java @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 ```java public void saveList(String key, ArrayList<String> values) { stringRedisTemplate.opsForList().rightPushAll(key, values); } ``` - 取得列表中的所有值。 - Controller ```java @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 ```java public List<String> getList(String key) { return stringRedisTemplate.opsForList().range(key, 0, -1); } ``` - 新增一個值到列表的尾部 - Controller ```java @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 ```java public void addAValueToEndOfList(String key, String value) { stringRedisTemplate.opsForList().rightPush(key, value); } ``` - 新增一個值到列表的頭部 - Controller ```java @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 ```java @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 ```java @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 ```java public String popAValueFromEndOfList(String key) { return stringRedisTemplate.opsForList().rightPop(key); } ``` - 從列表的頭部 pop 取得一個值,並且於列表中移除。 - Controller ```java @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 ```java public String popAValueFromBeginningOfList(String key) { return stringRedisTemplate.opsForList().leftPop(key); } ``` - 取得某個 index 的值。 - Controller ```java @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 ```java public String getAValueFromListByIndex(String key, long index) { return stringRedisTemplate.opsForList().index(key, index); } ``` - 以 index 從列表中移除某個值。 - Controller ```java @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 ```java public void removeAValueFromListByIndex(String key, long index, String value) { stringRedisTemplate.opsForList().remove(key, index, value); } ``` - 移除列表 - Controller ```java @Operation(summary = "Remove a list", description = "Remove a list") @Tag(name = "List") @GetMapping("/removeList") public void removeList(String key) { service.removeList(key); } ``` - Service ```java public void removeList(String key) { stringRedisTemplate.delete(key); } ``` ### 集合(Sets) - 新增一個集合到 Redis 中。 - Controller ```java @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 ```java public void saveSet(String key, ArrayList<String> value) { stringRedisTemplate.opsForSet().add(key, value.toArray(new String[0])); } ``` - 取得集合中的所有值。 - Controller ```java @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 ```java public List<String> getSet(String key) { return new ArrayList<>(Objects.requireNonNull(stringRedisTemplate.opsForSet().members(key))); } ``` - 從集合中移除某個值。 - Controller ```java @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 ```java public void removeAValueFromSet(String key, String value) { stringRedisTemplate.opsForSet().remove(key, value); } ``` - 比較兩個集合的差異。 - Controller ```java @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 ```java 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 ```java @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 ```java 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 ```java @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 ```java public Map<Object, Object> getHash(String key) { return stringRedisTemplate.opsForHash().entries(key); } ``` - 取得 hash 中的某個欄位。 - Controller ```java @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 ```java public String getHashValue(String key, String field) { return (String) stringRedisTemplate.opsForHash().get(key, field); } ``` - 刪除 hash 中的某個欄位。 - Controller ```java @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 ```java public void deleteHashField(String key, String field) { stringRedisTemplate.opsForHash().delete(key, field); } ``` - 判斷 hash 中是否存在某個欄位。 - Controller ```java @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 ```java public boolean existsHashField(String key, String field) { return stringRedisTemplate.opsForHash().hasKey(key, field); } ``` - 刪除 hash。 - Controller ```java @Operation(summary = "Delete a hash", description = "Delete a hash") @Tag(name = "Hash") @GetMapping("/deleteHash") public void deleteHash(String key) { service.deleteHash(key); } ``` - Service ```java 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` 類別。 ```java @RestController public class LuaController { public final LuaService luaService; public LuaController(LuaService luaService) { this.luaService = luaService; } } ``` 2. 新開一個 'LuaService' 類別,並且加上 `@Service` 註解,並注入 `StringRedisTemplate` 類別。 ```java @Service public class LuaService { private final StringRedisTemplate stringRedisTemplate; private final DefaultRedisScript<String> redisScript; public LuaService(StringRedisTemplate stringRedisTemplate) { this.stringRedisTemplate = stringRedisTemplate; redisScript = new DefaultRedisScript<>(); } } ``` 3. 在存放靜態資源的資料夾 `resources` 中,新增一個 Lua 脚本檔案,例如 `test.lua`。 4. 在 `test.lua` 中寫入 Lua 脚本。 ```lua -- 設置一個鍵值對 redis.call('SET', 'mykey', 'myvalue') -- 獲取鍵的值 local value = redis.call('GET', 'mykey') -- 返回鍵的值 return value ``` 5. 在 `LuaService` 類別中新增一個方法,用來執行 Lua 脚本。 ```java public String executeLuaScript() { redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("test.lua"))); redisScript.setResultType(String.class); return stringRedisTemplate.execute(redisScript, Collections.emptyList()); } ``` 6. 在 `LuaController` 類別中新增一個方法,用來執行 Lua 脚本。 ```java @Operation(summary = "Execute Lua script", description = "Execute Lua script") @Tag(name = "Lua") @GetMapping("/executeLuaScript") public String executeLuaScript() { return luaService.executeLuaScript(); } ``` 7. 最後我們就到我們的 Swagger 介面中,測試我們的 API 吧!觸發以後就會回傳 `myvalue` 囉! 8. 也可以到我們的 RedisInsight 中,查看是否有成功設置 `mykey` 的值。 ## Redis 的 Pub/Sub 模式 Redis 的 Pub/Sub 模式是一種消息傳遞模式,相較於其他的消息傳遞工具而言,是屬於輕量、簡單的存在。就我個人的經歷而言,這只能用在一些簡單且不重要的訊息傳遞上,如果是一些重要的訊息傳遞,還是建議使用功能比較完整且安全的消息傳遞工具。例如 RabbitMQ、Kafka 等等。 我在後面提供的原始碼中有實作一個簡單的 Pub/Sub 模式,我就不多做解釋了。 另外我有看到這篇文章[Redis Pub/Sub 是什麼、會造成什麼問題呢?](https://medium.com/jerrynotes/redis-pub-sub-%E6%98%AF%E4%BB%80%E9%BA%BC-%E6%9C%83%E9%80%A0%E6%88%90%E4%BB%80%E9%BA%BC%E5%95%8F%E9%A1%8C%E5%91%A2-ab5be1e5328d),介紹 Redis 的 Pub/Sub 模式,寫得很棒~!有興趣的話可以看看。 另外在 Redisinsight 中,也有提供 Pub/Sub 的功能,可以用來觀察訊息的傳遞,不過你需要先去把它 subscribe,才能看到訊息的傳遞。否則即使你有使用 publish 來發送訊息,也是看不到的。 ![image](https://hackmd.io/_uploads/By4Zoq0U0.png) ## 持久化 Redis 提供了兩種持久化的方式,分別是 RDB 和 AOF。 在一開始的時候我們大概介紹了,這邊我們就不多廢話了。 直接來看怎麼設定吧! > 我們可以在 Redis 的[官方網站](https://redis.io/docs/latest/operate/oss_and_stack/management/config-file/)上找到完整個 Redis config 設定。 > 跟大部分的 config 檔案一樣,我們只需要在 config 檔案中加入我們要的設定就好了。所以以下我們只會列出我們要的設定。 1. 先在我們的專案底下開一個資料夾叫做 `redis`,然後在裡面新增一個檔案叫做 `redis.conf`。 2. 在 `redis.conf` 中加入以下設定。 > 這裡稍微注意一下,註解如果寫在你的設定後面,例如 `save 30 1 # 這是一個註解`,可能會導致錯誤,所以建議註解寫在設定上面。 ```conf # 啟用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 ``` 3. 接下來要去修改我們的 `docker-compose.yml` 檔案,把我們的 `redis.confg` 掛載到我們的 Redis 容器中,同時也要把我們在 Redis 容器中產生的持久化檔案(RDB 和 AOF)掛載到我們的本機中,這樣當我們的 Redis 容器被刪除時,我們的持久化檔案還是會存在。 ```yml 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 ``` 4. 接下來就在我們的 `docker-compose.yml` 檔案所在的資料夾中,開啟終端機,輸入以下指令,啟動我們的 Redis 容器。這裡我們不使用 `-d` 參數,我們來觀察一下 Redis 的 log。 ```bash docker-compose up ``` 我們可以看到 Redis 的 log 中有以下訊息,啟動或讀取了我們的 RDB 及 AOF 檔案。 ![image](https://hackmd.io/_uploads/SylQiqR8C.png) 5. 我們也可以發現這時在我們的 `redis-data` 資料夾中,多了三個檔案。當我們的 container 被刪除又重新啟動時,就會把這三個檔案掛載到我們的 Redis 容器中,這樣就可以保證我們的 Redis 持久化檔案不會遺失。 - appendonly.aof.1.base.rdb,這是我們的 RDB 檔案。 - appendonly.aof.1.incr.aof,這是我們的 AOF 檔案。 - appendonly.aof.manifest,這是我們的 AOF manifest 檔案。 6. 我們也可以使用已下指令到 redis 的容器中看看我們的持久化檔案。 ```bash docker exec -it my-redis /bin/bash ``` 進入容器後,我們可以使用 `ls` 指令看看我們的持久化檔案。 ```bash ls /data/appendonlydir ``` 我們可以看到上述的那三個檔案。 7. 接下來我們可以使用前面寫好的那些 Redis 的操作,來存入一些資料,接著把 Redis 的容器刪除,然後再重新啟動 Redis 的容器,我們就可以看到我們的資料還在。 ## Redis Cluster ### Redis Cluster 的結構 Redis Cluster 是一種分散式的架構。 ```mermaid 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 訊息 ![image](https://hackmd.io/_uploads/BkIPo50IA.png) - 當一個 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 的[官方網站](https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/)上看看更詳細的介紹。 ### 使用 Docker 安裝 Redis Cluster - 先開一個叫做 `.env` 的檔案,並且加入以下設定。當使用 `docker compose up` 時,Docker 會讀取這個檔案,並且將這些設定注入到我們的 `docker-compose.yml` 檔案中。這裡的 `ip` 請使用你正在使用的 網路 IPv4 位址。你可以使用 `ipconfig` 或 `ifconfig` 來查看你的 IPv4 位址。 ```env 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)。相關內容可以參考[官方文件](https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/) - 集群總線是一個節點間的通信通道,使用二進制協議進行通信,這種協議更適合節點間的信息交換,因為它佔用的帶寬和處理時間較少。節點使用集群總線進行故障檢測、配置更新、故障轉移授權等操作。客戶端不應該嘗試與集群總線端口通信,而應使用 Redis 命令端口。然而,請確保在防火牆中開放這兩個端口,否則 Redis 集群節點之間將無法通信。 - 為了讓 Redis Cluster 正常工作: - 集群總線端口必須能夠被所有其他集群節點訪問 - 客戶端通信端口(通常為 6379),用於與客戶端通信,並且應對所有需要訪問集群的客戶端開放,以及所有使用該端口進行鍵遷移的其他集群節點開放。 - Docker 使用 NAT(Network Address Translation) 來實現容器與外部網路的通信,因此在容器中無法直接訪問外部網路。因此,我們需要將容器的端口映射到主機的端口,這樣我們才能通過主機的端口訪問容器的端口。 - 然而 Redis Cluster 目前並不支援 NAT,也就是無法使用 IP 地址或 TCP 端口重新映射的環境。 - 如果要使 Docker 與 Redis Cluster 兼容,需要使用 Docker 主機網絡模式。可以參考[這裡](https://docs.docker.com/network/)。 - `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` 容器啟動後才會啟動。 ```yml 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。 ```Dockerfile 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 檔案。 ```conf 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 ```bash docker-compose up ``` - 當你在 console 中看到以下訊息時,表示 Redis Cluster 啟動成功。 ```bash 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](https://start.spring.io/) 創建一個新的 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 ```xml <dependency> <groupId>org.springdoc</groupId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <version>2.0.2</version> </dependency> ``` 3. spring boot 的 application.yml。 IP 就使用跟前述的 `.env` 檔案中一樣的 IP。 ```yml 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} ``` 4. 另外開一個 `redis-cluster-connection-info.properties` 檔案,並且加入以下設定。 ```properties ip=192.168.60.62 redis.password=pass.123 ``` 5. SwaggerConfig.java ```java @OpenAPIDefinition( info = @Info( title = "Spring Boot integration with Redis Cluster practice", version = "0.0" ) ) @Configuration public class SwaggerConfig { } ``` 6. MyController.java ```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(); } } ``` 7. MyService.java ```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; } } ``` 8. Redis Cluster 其實在一般的 Redis 操作上沒有太大的差異,但提供了 數據分片(Data sharding)、自動故障轉移(Automatic Failover)、高可用性(Availability)、Load Balancing 等功能。這裡我們就寫一些用來測試數據分片、自動故障轉移、高可用性的 API。程式碼我認為寫得很清楚了,所以我就不過多解釋,有興趣的到最後面把我的程式碼下載下來看看吧。 9. 新加 redis 的 WriteToMasterReadFromReplicaConfig 來設定 redis cluster 的讀寫分離 ```java @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); } } ``` 10. TestController.java ```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; } } ``` 11. TestService.java ```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](https://hackmd.io/@ohQEG7SsQoeXVwVP2-v06A/Bk1ZhSWca) ## 總結 ## Reference - [https://redis.dev.org.tw/docs](https://redis.dev.org.tw/docs) - [https://redis.io/docs](https://redis.io/docs) - [https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/](https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/)