Table of contents
1 Redis 簡介
一般既 web applications 都會將最重要既資料(例如客戶資料、交易數據)放喺一個或者多個(如果係 microservice architecture)RDBMS 既 databases 裡面,而呢啲 databases 既資料都可以被視作為 single source of truth。不過,資料可以分好多種,除左最重要、改既話一定要直接改 database 既紀錄果啲,仲有一啲比如係用戶網頁登入既 session states、多國語言既翻譯文本,或者係一啲基本上都唔會點改變既業務配置或者程序配置。為左減少 database 既存取、加快查詢速度,我地可以用唔同既 caching library,例如 Redis、Memcached、Ehcache、Caffeine。
關於 Redis:
- Key-value pair 既 cache data。
- 佢係一個獨立既 infrastructure,可以用作 distributed 既 cache provider,畀好多個 Redis client 既 application instances 去存取。
- 佢既運作方式係 in-memory cache。
- 不過佢既 data 都可以 save 落 disk(例如 Redis server shutdown 果陣,或者用 Redis command)。
- 支援 transaction,隔離狀態咁一次過執行多個 commands,但係就唔 ACID(大概只可以做到 isolation,因為唔支援 rollback)。
- 我地可以建立 Redis cluster、多個 replicas 以及 sentinels 去達到 high availability。
2 安裝
2.1 Docker image
docker container run -d --rm -p 6379:6379 -v "C:/docker-data/redis:/data" --name redis redis:latest redis-server --save 10 1 --requirepass redispw
Redis Docker image 裡面已經包含 Redis CLI,所以我地只需要接連入去個 container 既 CLI 就可以用到。
連接 Redis CLI:
- 方法一
- 安裝 Another Redis Desktop Manager。
- 方法二
- 去 Docker Desktop 打開
redis
container。
- 撳「Exec」分頁。
- 執行:
redis-cli -a redispw
- 方法三
- 喺 Windows Command Prompt 或者 macOS Terminal 執行:
docker container exec -it <redis container name/ID> redis-cli -a redispw
2.2 Windows(免安裝)
- 去 tporadowski/redis GitHub 下載 ZIP 版既 Redis server。
- 解壓縮個 ZIP 檔。
- 執行 command:
redis-server.exe redis.windows.conf
2.3 macOS(Homebrew)
- 執行 command:
brew install redis
- 執行 command:
redis-server
3 動手寫
Project structure:
src/main/java
/code
/config
/controller
MyReadController.java
MyWriteController.java
/dto
/service
MyReadService.java
MyWriteService.java
MainApplication.java
3.1 Maven dependencies
- 新、舊版本既 Spring Data Redis(由 Spring Boot Starter Data Redis 引入)對於透過 annotations 黎做 Redis caching 既差異:
- 新版
2.x
既 RedisCacheManager
裡面用到 RedisCacheConfiguration
,所以係用 RedisCacheConfiguration
黎配置個別 cache,如果直接配置 RestTemplate
bean 係唔會起到作用。
- 舊版
1.x
係直接配置 RestTemplate
bean。
- 新、舊版本既 Spring Boot Starter Data Redis 引入既 Redis client 既差異:
- Spring Boot Starter Data Redis
1.x
默認用 Jedis,冇引入 Lettuce 既 dependencies。
- Spring Boot Starter Data Redis
2.x
、3.x
默認用 Lettuce,冇引入 Jedis 既 dependencies。
- 新、舊版本既 Spring Data Redis 配置既差異:
- Spring Boot
1.x
、2.x
既配置係以 spring.redis
開頭。
- Spring Boot
3.x
既配置係以 spring.data.redis
開頭。
1<parent>
2 <groupId>org.springframework.boot</groupId>
3 <artifactId>spring-boot-starter-parent</artifactId>
4 <version>3.4.0</version>
5</parent>
6
7<dependencies>
8 <dependency>
9 <groupId>org.springframework.boot</groupId>
10 <artifactId>spring-boot-starter-web</artifactId>
11 </dependency>
12
13 <dependency>
14 <groupId>org.springframework.boot</groupId>
15 <artifactId>spring-boot-starter-data-redis</artifactId>
16 </dependency>
17
18 <dependency>
19 <groupId>org.apache.commons</groupId>
20 <artifactId>commons-lang3</artifactId>
21 </dependency>
22
23 <dependency>
24 <groupId>org.projectlombok</groupId>
25 <artifactId>lombok</artifactId>
26 <scope>provided</scope>
27 </dependency>
28</dependencies>
3.2 Application 配置
1spring:
2 cache:
3 type: redis
4 data:
5 redis:
6 host: localhost
7 port: 6379
8 database: 0
9 password: redispw
10
11logging.level:
12 org.springframework.cache: TRACE
13
3.3 寫 Java code
3.3.1 Services (caching layer)
MyReadService.java
:
1@Slf4j
2@CacheConfig(cacheManager = "defaultCacheManager", cacheNames = { "default-cache-name" })
3@Service
4public class MyReadService {
5
6 // 因為用左 RedisCacheConfiguration.disableCachingNullValues(),如果唔加 unless 就會有 exception。
7 @Cacheable(key = "'null'", unless = "#result == null")
8 public Object getNull() {
9 return null;
10 }
11
12 @Cacheable(key = "'string'")
13 public String getString() {
14 return RandomStringUtils.secure().nextAlphanumeric(10000);
15 }
16
17 @Cacheable(key = "'int'")
18 public int getInt() {
19 return Integer.MAX_VALUE;
20 }
21
22 @Cacheable(key = "'long'")
23 public long getLong() {
24 return Long.MAX_VALUE;
25 }
26
27 @Cacheable(key = "'float'")
28 public float getFloat() {
29 return Float.MIN_VALUE;
30 }
31
32 @Cacheable(key = "'double'")
33 public double getDouble() {
34 return Double.parseDouble("0." + RandomStringUtils.secure().nextNumeric(325));
35 }
36
37 @Cacheable(key = "'boolean'")
38 public boolean getBoolean() {
39 return true;
40 }
41
42 @Cacheable(key = "'biginteger'")
43 public BigInteger getBigInteger() {
44 return new BigInteger(Integer.MAX_VALUE + "");
45 }
46
47 @Cacheable(key = "'bigdecimal'")
48 public BigDecimal getBigDecimal() {
49 final BigDecimal value = new BigDecimal("0." + RandomStringUtils.secure().nextNumeric(325));
50 log.info("Caching BigDecimal value: {}", value);
51 return value;
52 }
53
54 @Cacheable(key = "'date'")
55 public Date getDate() {
56 return new Date();
57 }
58
59 @Cacheable(key = "'timestamp'")
60 public Timestamp getTimestamp() {
61 return new Timestamp(System.currentTimeMillis());
62 }
63
64 @Cacheable(key = "'instant'")
65 public Instant getInstant() {
66 return Instant.now();
67 }
68
69 @Cacheable(key = "'localdatetime'")
70 public LocalDateTime getLocalDateTime() {
71 return LocalDateTime.now();
72 }
73
74 @Cacheable(key = "'optional-string'")
75 public Optional<String> getOptionalString() {
76 return Optional.of(UUID.randomUUID().toString());
77 }
78
79 @Cacheable(key = "'string-list'")
80 public List<String> getStringList() {
81 return List.of(UUID.randomUUID().toString());
82 }
83
84 @Cacheable(key = "'string-map'")
85 public Map<String, String> getStringMap() {
86 return Map.of("my-key", UUID.randomUUID().toString());
87 }
88
89 @Cacheable(key = "'dto'")
90 public MyDto getDto() {
91 return constructDto();
92 }
93
94 // XXX Doesn't work
95 @Cacheable(key = "'dto-with-optional-field'")
96 public MyDto getDtoWithOptionalField() {
97 return constructDtoWithOptional();
98 }
99
100 @Cacheable(key = "'optional-dto'")
101 public Optional<MyDto> getOptionalDto() {
102 return Optional.of(constructDto());
103 }
104
105 @Cacheable(key = "'dto-list'")
106 public List<MyDto> getDtoList() {
107 return List.of(constructDto());
108 }
109
110 @Cacheable(key = "'dto-map'")
111 public Map<MyDto, MyDto> getDtoMap() {
112 return Map.of(constructDto(), constructDto());
113 }
114
115
116
117 private MyDto constructDto() {
118 final MyDto myDto = new MyDto();
119 myDto.setMyNull(null);
120 myDto.setMyString(UUID.randomUUID().toString());
121 myDto.setMyInt(Integer.MAX_VALUE);
122 myDto.setMyLong(Long.MAX_VALUE);
123 myDto.setMyFloat(Float.MIN_VALUE);
124 myDto.setMyDouble(Double.parseDouble("0." + RandomStringUtils.secure().nextNumeric(325)));
125 myDto.setMyBoolean(true);
126 myDto.setMyBigInteger(new BigInteger(Integer.MAX_VALUE + ""));
127 myDto.setMyBigDecimal(new BigDecimal("0." + RandomStringUtils.secure().nextNumeric(325)));
128 myDto.setMyDate(new Date());
129 myDto.setMyTimestamp(new Timestamp(System.currentTimeMillis()));
130 myDto.setMyInstant(Instant.now());
131 myDto.setMyLocalDateTime(LocalDateTime.now());
132 myDto.setMyEnum(MyEnum.TEST_VALUE);
133 myDto.setMyStringList(List.of(UUID.randomUUID().toString()));
134 myDto.setMyStringMap(Map.of("my-key", UUID.randomUUID().toString()));
135
136 return myDto;
137 }
138
139 private MyDto constructDtoWithOptional() {
140 final MyDto myDto = constructDto();
141 myDto.setMyOptionalString(Optional.of(UUID.randomUUID().toString()));
142
143 return myDto;
144 }
145}
MyWriteService.java
:
1@Slf4j
2@CacheConfig(cacheManager = "defaultCacheManager", cacheNames = { "default-cache-name" })
3@Service
4public class MyWriteService {
5
6 @CachePut(key = "'string'")
7 public String updateString() {
8 return RandomStringUtils.secure().nextAlphanumeric(10000);
9 }
10
11 @CacheEvict(key = "#myKey")
12 public void delete(String myKey) {
13 // Do nothing
14 }
15
16 @CacheEvict(allEntries = true)
17 public void deleteAll() {
18 // Do nothing
19 }
20}
解釋:
@Cacheable
- 如果存在 cache 紀錄(cache hit),就返回 cache 紀錄;否則(cache miss),就執行 method 裡面既 code,再將 return value 放入 cache。
@CachePut
- 用 return value 新增或者覆蓋現有 cache 紀錄。
@CacheEvict
- 如果存在 cache 紀錄,就刪除 cache 紀錄。
- 可以用
allEntries = true
黎刪曬所有 value
下既所有 cache 紀錄(不限 key
)。
key
attribute
- 可以用 method 名、method parameters、return value 以及自定義文字去組成 cache 紀錄既 key 名。
- 最後得出既 cache key 名會係
<cache name>::<cache key>
既 pattern,例如 default-cache-name::dto-list
。
unless
attribute(@Cacheable
、@CachePut
)
- 用黎喺攞到真既 return value 之後,根據自定義既邏輯去選擇性咁去做定唔做相關 caching 操作。
3.3.2 Controllers
MyReadController.java
:
1@RestController
2@RequestMapping("/test/cache")
3public class MyReadController {
4
5 @Autowired MyReadService myReadService;
6
7 @GetMapping("/null")
8 public Object getNull() {
9 return myReadService.getNull();
10 }
11
12 @GetMapping("/string")
13 public String getString() {
14 return myReadService.getString();
15 }
16
17 @GetMapping("/int")
18 public int getInt() {
19 return myReadService.getInt();
20 }
21
22 @GetMapping("/long")
23 public long getLong() {
24 return myReadService.getLong();
25 }
26
27 @GetMapping("/float")
28 public String getFloat() {
29 return new BigDecimal(myReadService.getFloat() + "").toPlainString();
30 }
31
32 @GetMapping("/double")
33 public String getDouble() {
34 return new BigDecimal(myReadService.getDouble() + "").toPlainString();
35 }
36
37 @GetMapping("/boolean")
38 public boolean getBoolean() {
39 return myReadService.getBoolean();
40 }
41
42 @GetMapping("/biginteger")
43 public BigInteger getBigInteger() {
44 return myReadService.getBigInteger();
45 }
46
47 @GetMapping("/bigdecimal")
48 public BigDecimal getBigDecimal() {
49 return myReadService.getBigDecimal();
50 }
51
52 @GetMapping("/date")
53 public Date getDate() {
54 return myReadService.getDate();
55 }
56
57 @GetMapping("/timestamp")
58 public Timestamp getTimestamp() {
59 return myReadService.getTimestamp();
60 }
61
62 @GetMapping("/instant")
63 public Instant getInstant() {
64 return myReadService.getInstant();
65 }
66
67 @GetMapping("/localdatetime")
68 public LocalDateTime getLocalDateTime() {
69 return myReadService.getLocalDateTime();
70 }
71
72 @GetMapping("/optional-string")
73 public Optional<String> getOptionalString() {
74 return myReadService.getOptionalString();
75 }
76
77 @GetMapping("/string-list")
78 public List<String> getStringList() {
79 return myReadService.getStringList();
80 }
81
82 @GetMapping("/string-map")
83 public Map<String, String> getStringMap() {
84 return myReadService.getStringMap();
85 }
86
87 @GetMapping("/dto")
88 public MyDto getDto() {
89 return myReadService.getDto();
90 }
91
92 @GetMapping("/dto-with-optional-field")
93 public MyDto getDtoWithOptionalField() {
94 return myReadService.getDtoWithOptionalField();
95 }
96
97 @GetMapping("/optional-dto")
98 public Optional<MyDto> getOptionalDto() {
99 return myReadService.getOptionalDto();
100 }
101
102 @GetMapping("/dto-list")
103 public List<MyDto> getDtoList() {
104 return myReadService.getDtoList();
105 }
106
107 @GetMapping("/dto-map")
108 public Map<MyDto, MyDto> getDtoMap() {
109 return myReadService.getDtoMap();
110 }
111}
MyWriteController.java
:
1@RestController
2@RequestMapping("/test/cache")
3public class MyWriteController {
4
5 @Autowired MyWriteService myWriteService;
6
7 @PutMapping("/string")
8 public String getString() {
9 return myWriteService.updateString();
10 }
11
12 @DeleteMapping("/one/{key}")
13 public void delete(@PathVariable(name = "key", required = true) String key) {
14 myWriteService.delete(key);
15 }
16
17 @DeleteMapping("/all")
18 public void deleteAll() {
19 myWriteService.deleteAll();
20 }
21}
3.3.3 自定義 RedisCacheManager
RedisConfig.java
:
1@EnableCaching
2@Configuration
3public class RedisConfig {
4 private static final Duration TTL = Duration.ofMinutes(10);
5
6 @Bean
7 public RedisCacheManager defaultCacheManager(RedisConnectionFactory connectionFactory,
8 RedisCacheConfiguration redisCacheConfig) {
9 return RedisCacheManager.builder(connectionFactory)
10 .withInitialCacheConfigurations(Map.of("default-cache-name", redisCacheConfig))
11 .build();
12 }
13
14 @Bean
15 public RedisCacheConfiguration defaultRedisCacheConfig() {
16
17 final ObjectMapper objectMapper = new ObjectMapper();
18 objectMapper.registerModule(new JavaTimeModule());
19 objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
20 objectMapper.activateDefaultTyping(objectMapper.getPolymorphicTypeValidator(),
21 ObjectMapper.DefaultTyping.EVERYTHING,
22 JsonTypeInfo.As.WRAPPER_OBJECT);
23
24 final SerializationPair<String> keySerializer = SerializationPair.fromSerializer(
25 RedisSerializer.string());
26 final SerializationPair<?> valueSerializer = SerializationPair.fromSerializer(
27 new GenericJackson2JsonRedisSerializer(objectMapper));
28
29 return RedisCacheConfiguration
30 .defaultCacheConfig()
31 .disableCachingNullValues()
32 .entryTtl(TTL)
33 .serializeKeysWith(keySerializer)
34 .serializeValuesWith(valueSerializer);
35 }
36}
解釋:
@EnableCaching
- 一定要加上呢個 annotation 先會開啟 caching 功能,否則所有 caching 相關既 annotations 都唔會生效。
- 配置
RedisCacheConfiguration
- 根據
RedisCacheConfiguration#defaultCacheConfig
,Redis caching 默認既 value serializer 係 JdkSerializationRedisSerializer
。
- 呢個 value serializer 會用 Java 自帶既 serialization 功能。
JdkSerializationRedisSerializer
裡面用到既 DefaultSerializer
喺 serialize object 之前會 check 佢個 Java type 係咪 implement Serializable
。
- 所以如果用默認既
RedisCacheConfiguration
,我地任何自定義既 Java types 都一定要 implement Serializable
。
- 另一個唔好處係,由 Java serialize 出黎既 cache value 都會係亂碼,用一般既 Redis GUI 工具唔會睇得出 data 係咩,咁就冇可能做到 troubleshooting。
- 一般為左 backward compatibility,都會轉用 JSON 格式黎儲存 cache value。
- 呢個其實係一個難題,因為 runtime 既時候,Spring Data Redis 唔知道要將 JSON data 以及每一個 nested object 既每一個 entry deserialize 成邊一個 Java type,所以就變左需要將 Java types 既資訊放入 cache data 裡面。
- 我地需要將 value serializer 改成用 Jackson library 既
GenericJackson2JsonRedisSerializer
。
- 我地唔可以用 no-arg constructor,因為默認既情況下會用左
new ObjectMapper()
,唔支援 Java 8 既 Date/Time API,例如 LocalDateTime
。
- 所以我地會 pass 我地自己配置好既
ObjectMapper
object 入去 new GenericJackson2JsonRedisSerializer(ObjectMapper)
。
ObjectMapper
可以畀我地 register JavaTimeModule
,咁就可以支援 Java 8 既 Date/Time API,例如 LocalDateTime
。
ObjectMapper
既 activateDefaultTyping()
可以畀我地揀 cache value 裡面要唔要、點樣儲存 Java type 資訊,有機會影響到最終既 JSON 數據。
JsonTypeInfo.As.WRAPPER_OBJECT
- 會將實際既 Java type 放落一個 JSON object 既 key,而呢個 JSON object 既 value 就係個實際既 cache value。
- 唯一壞處係,個 cache value 既 JSON data structure 會唔同左,有別於冇 Java type 資訊、純 data 既 JSON data structure。
disableCachingNullValues
- Return value 係
null
既話會出 exception,除非有用 unless
attribute。
entryTtl
- Cache 紀錄既存活時間(time-to-live)。
4 測試
4.1 測試 @Cacheable
- 運行 Redis。
- 運行個 app。
- 執行以下既 cURL commands。
1curl -X GET localhost:8080/test/cache/null
2curl -X GET localhost:8080/test/cache/string
3curl -X GET localhost:8080/test/cache/int
4curl -X GET localhost:8080/test/cache/long
5curl -X GET localhost:8080/test/cache/float
6curl -X GET localhost:8080/test/cache/double
7curl -X GET localhost:8080/test/cache/boolean
8curl -X GET localhost:8080/test/cache/biginteger
9curl -X GET localhost:8080/test/cache/bigdecimal
10curl -X GET localhost:8080/test/cache/date
11curl -X GET localhost:8080/test/cache/timestamp
12curl -X GET localhost:8080/test/cache/instant
13curl -X GET localhost:8080/test/cache/localdatetime
14curl -X GET localhost:8080/test/cache/optional-string
15curl -X GET localhost:8080/test/cache/string-list
16curl -X GET localhost:8080/test/cache/string-map
17curl -X GET localhost:8080/test/cache/dto
18curl -X GET localhost:8080/test/cache/optional-dto
19curl -X GET localhost:8080/test/cache/dto-list
20curl -X GET localhost:8080/test/cache/dto-map
預期結果:
- 第一次執行既結果:
- Caching 既
TRACE
logs 會有 No cache entry for key
以及 Creating cache entry for key
。
- 第一次執行既結果:
- Response body 同第一次既 response body 完全一樣。
- Caching 既
TRACE
logs 會有 Cache entry for key 'xxx' found
。
- 如果喺
@Cacheable
既 method body 裡面 set breakpoint,就會見到佢冇入到去 method body。
4.2 測試 @Cacheable
- 有 Optional
field 既 DTO
- 運行 Redis。
- 運行個 app。
- 執行以下既 cURL command。
curl -X GET localhost:8080/test/cache/dto-with-optional-field
預期結果:
解釋:
- 雖然我地可以額外對 value serializer 既
ObjectMapper
register Jdk8Module
…
- 咁做支援到 serialize
MyDto
class 裡面既 Optional
field。
- 但其實 serialize 出黎既 JSON object entry 係冇辦法被 deserialize 返做
Optional
field。
- 咁係因為我地用左
JsonTypeInfo.As.WRAPPER_OBJECT
,而 Jackson library 對 Optional
既處理有啲問題,導致到佢冇將任何 Java types 既資訊放入去 JSON object entry 裡面(佢係需要放 2
個 Java types 既資訊——Optional
以及實際既 Java type),所以當 deserialize 既時候就會 throw exception,話 Unexpected token (VALUE_STRING), expected START_OBJECT: need JSON Object to contain As.WRAPPER_OBJECT type information for class
。
- 唯一解決方法係改用
JsonTypeInfo.As.PROPERTY
,但呢個方法既壞處係佢會令 Map
type 既 data 多左個 @class
entry。
- 其實 Java 自帶既 serialization 功能都唔支援 serialize
Optional
,咁係因為 Optional
本身冇 implement Serializable
。
4.3 測試 @CachePut
- 運行 Redis。
- 運行個 app。
- 執行以下既 cURL commands。
curl -X PUT localhost:8080/test/cache/string
預期結果:
- 可以成功更新相關既 cache 紀錄,每次執行之後既 cache value 都會唔同。
4.4 測試 @CacheEvict
- 運行 Redis。
- 運行個 app。
- 執行以下既 cURL commands。
curl -X DELETE localhost:8080/test/cache/one/string
curl -X DELETE localhost:8080/test/cache/one/dto
預期結果:
curl -X DELETE localhost:8080/test/cache/all
預期結果:
5 參考資料