SpringBoot中Redis的緩存更新策略詳解
常見的緩存更新策略
緩存一般是為了應對高并發(fā)場景、緩解數(shù)據(jù)庫讀寫壓力,而將數(shù)據(jù)存儲在讀寫更快的某種存儲介質(zhì)中(如內(nèi)存),以加快讀取數(shù)據(jù)的速度。
緩存一般分為本地緩存(如java堆內(nèi)存緩存)、分布式緩存(如Redis)等。
既然是緩存,就意味著緩存中暫存的數(shù)據(jù)只是個副本,也就意味著需要保證副本和主數(shù)據(jù)之間的數(shù)據(jù)一致性,這就是接下來要分析的緩存的更新。
常見的緩存更新策略有:
- 先刪緩存,再更新數(shù)據(jù)庫
- 先更新數(shù)據(jù)庫,再刪緩存
- 先更新數(shù)據(jù)庫,再更新緩存
- read/write through
- 寫回。在更新數(shù)據(jù)的時候,只更新緩存,不更新數(shù)據(jù)庫,而我們的緩存會異步地批量更新數(shù)據(jù)庫
一、先刪緩存再更新數(shù)據(jù)庫
很明顯這個邏輯是有問題的,假設(shè)有兩個并發(fā)操作,一個操作更新、另一個操作查詢,更新操作刪除緩存后還沒來得及更新數(shù)據(jù)庫,此時另一個用戶發(fā)起了查詢操作,它因沒有命中緩存進而從數(shù)據(jù)庫讀,此時第一個操作還沒到更新數(shù)據(jù)庫的階段,讀取到的是老數(shù)據(jù),接著寫到緩存中,導致緩存中數(shù)據(jù)變成臟數(shù)據(jù),并且會一直臟下去直到緩存過期或發(fā)起新的更新操作。
- 緩存初始值:A = 1
- 數(shù)據(jù)庫初始值:A = 1
- 緩存最終值:A = 1
- 數(shù)據(jù)庫最終值:A = 100
這個問題一般有兩種處理方式:
- 加鎖。鎖,意味著要去解決并發(fā)問題,那么也就意味著并發(fā)的處理會被串行的處理,性能自然會略低。
- 對key加上過期時間,把控好這個過期時間,不過業(yè)務(wù)上本身是具備容忍這個時間內(nèi)的數(shù)據(jù)不一致的問題方可。
- 降低出現(xiàn)這個問題的概率
二、先更新數(shù)據(jù)庫,再刪緩存
這是目前業(yè)界最常用的方案。雖然它同樣不夠完美,但問題發(fā)生的概率很小,它的讀流程和寫流程見下圖(圖片來源于網(wǎng)絡(luò),侵刪):


寫操作先更新數(shù)據(jù)庫,更新成功后使緩存失效。讀操作先讀緩存,緩存中讀到了則直接返回,緩存中讀不到再讀數(shù)據(jù)庫,之后再將數(shù)據(jù)庫數(shù)據(jù)加載到緩存中。
但它同樣也有問題,如下圖,查詢操作未命中緩存,接著讀數(shù)據(jù)庫老數(shù)據(jù)之后、寫緩存之前,此時另一個用戶發(fā)起了更新操作更新了數(shù)據(jù)庫并清了緩存,接著查詢操作將數(shù)據(jù)庫中老數(shù)據(jù)更新到緩存。這就導致緩存中數(shù)據(jù)變成臟數(shù)據(jù),并且會一直臟下去直到緩存過期或發(fā)起新的更新操作。
- 緩存初始值:空
- 數(shù)據(jù)庫初始值:A = 1
- 緩存最終值:A = 1
- 數(shù)據(jù)庫最終值:A = 100
為什么這種思路存在這么明顯的問題,卻還具有那么廣泛的應用呢?因為這個case實際上出現(xiàn)的概率非常低,產(chǎn)生這個case需要具備如下4個條件:
- 讀操作讀緩存失效
- 有個并發(fā)的寫操作
- 寫操作比讀操作更快
- 讀操作早于寫操作進入數(shù)據(jù)庫,晚于寫操作更新緩存
而實際上數(shù)據(jù)庫的寫操作會比讀操作慢得多,而且還要鎖表,而讀操作必需在寫操作前進入數(shù)據(jù)庫操作,而又要晚于寫操作更新緩存,所有的這些條件都具備的概率基本并不大。并且即使出現(xiàn)這個問題還有一個緩存過期時間來自動兜底。
三、先更新數(shù)據(jù)庫,再更新緩存
相對來講,理論上這種方式比先更新數(shù)據(jù)庫再刪緩存有著更高的讀性能,因為它事先準備好數(shù)據(jù)。
但由于要更新數(shù)據(jù)庫和緩存兩塊數(shù)據(jù),所以它的寫性能就比較低,而且關(guān)鍵在于它也會出現(xiàn)臟數(shù)據(jù),如下圖,兩個并發(fā)更新操作,分別出現(xiàn)一前一后寫數(shù)據(jù)庫、一后一前寫緩存,則最終緩存的數(shù)據(jù)是二者中前一次寫入的數(shù)據(jù),不是最新的。
- 緩存初始值:A = 1
- 數(shù)據(jù)庫初始值:A = 1
- 緩存最終值:A = 100
- 數(shù)據(jù)庫最終值:A = 200
四、read/write through 緩存代理
Read/Write Through套路是把更新數(shù)據(jù)庫(Repository)的操作由緩存自己代理了,所以,對于應用層來說,就簡單很多了。可以理解為,應用認為后端就是一個單一的存儲,而存儲自己維護自己的Cache。
數(shù)據(jù)庫由緩存代理,緩存未命中時由緩存加載數(shù)據(jù)庫數(shù)據(jù)然后應用從緩存讀,寫數(shù)據(jù)時更新完緩存后同步寫數(shù)據(jù)庫。應用只感知緩存而不感知數(shù)據(jù)庫。

五、寫回
這種方式英文名叫Write Behind 又叫 Write Back。一些了解Linux操作系統(tǒng)內(nèi)核的同學對write back應該非常熟悉,這不就是Linux文件系統(tǒng)的Page Cache的算法嗎?
是的,就是那個東西。這種模式是指在更新數(shù)據(jù)的時候,只更新緩存,不更新數(shù)據(jù)庫,而我們的緩存會異步地批量更新數(shù)據(jù)庫。

這種方式的問題在于數(shù)據(jù)不是強一致性的,而且可能會丟失(我們知道Unix/Linux非正常關(guān)機會導致數(shù)據(jù)丟失,就是因為這個)。
另外,Write Back實現(xiàn)邏輯比較復雜,因為他需要track有哪數(shù)據(jù)是被更新了的,需要刷到持久層上。
操作系統(tǒng)的write back會在僅當這個cache需要失效的時候,才會被真正持久起來,比如,內(nèi)存不夠了,或是進程退出了等情況,這又叫l(wèi)azy write。
六、以第一種方式舉例
- 失效:應用程序先從cache取數(shù)據(jù),沒有得到,則從數(shù)據(jù)庫中取數(shù)據(jù),成功后,放到緩存中。
- 命中:應用程序從cache中取數(shù)據(jù),取到后返回。
- 更新:先把數(shù)據(jù)存到數(shù)據(jù)庫中,成功后,再讓緩存失效。
大致流程如下:
獲取商品詳情舉例
- 從商品 Cache 中獲取商品詳情,如果存在,則返回獲取 Cache 數(shù)據(jù)返回。
- 如果不存在,則從商品 DB 中獲取。獲取成功后,將數(shù)據(jù)存到 Cache 中。則下次獲取商品詳情,就可以從 Cache 就可以得到商品詳情數(shù)據(jù)。
- 從商品 DB 中更新或者刪除商品詳情成功后,則從緩存中刪除對應商品的詳情緩存

添加maven依賴
這里是以MyBatis做數(shù)據(jù)庫DAO操作,Redis做緩存操作。
SpringBoot 2開始默認的Redis客戶端實現(xiàn)是 Lettuce ,同時你需要添加 commons-pool2 的依賴。
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>${jackson-databind-version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson-databind-version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>${jackson-databind-version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>com.vaadin.external.google</groupId>
<artifactId>android-json</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql-connector.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>${druid.version}</version>
</dependency>
<!-- MyBatis plus增強和springboot的集成-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatisplus-spring-boot-starter</artifactId>
<version>${mybatisplus-spring-boot-starter.version}</version>
</dependency>
</dependencies>注意上面我添加了Jackson的依賴,后面要用到。
配置application.yml
增加 Redis 相關(guān)配置
spring:
cache:
type: REDIS
redis:
cache-null-values: false
time-to-live: 600000ms
use-key-prefix: true
cache-names: userCache,allUsersCache
redis:
host: 127.0.0.1
port: 6379
database: 0
lettuce:
shutdown-timeout: 200ms
pool:
max-active: 7
max-idle: 7
min-idle: 2
max-wait: -1ms對應的配置類:
org.springframework.boot.autoconfigure.data.redis.RedisProperties
添加配置類
這里自定義RedisTemplate的配置類,主要是想使用Jackson替換默認的序列化機制:
@Configuration
public class RedisConfig {
/**
* redisTemplate 默認使用JDK的序列化機制, 存儲二進制字節(jié)碼, 所以自定義序列化類
* @param redisConnectionFactory redis連接工廠類
* @return RedisTemplate
*/
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
// 使用Jackson2JsonRedisSerialize 替換默認序列化
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(objectMapper);
// 設(shè)置value的序列化規(guī)則和 key的序列化規(guī)則
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.afterPropertiesSet();
return redisTemplate;
}
}使用演示
MyBatis實現(xiàn)的DAO層,以及User實體類我就不貼在這里了,只貼Service核心增刪改查操作:
@Service
@Transactional
public class UserService {
private Logger logger = LoggerFactory.getLogger(this.getClass());
@Resource
private UserMapper userMapper;
@Resource
private RedisTemplate<String, User> redisTemplate;
/**
* 創(chuàng)建用戶
* 不會對緩存做任何操作
*/
public void createUser(User user) {
logger.info("創(chuàng)建用戶start...");
userMapper.insert(user);
}
/**
* 獲取用戶信息
* 如果緩存存在,從緩存中獲取城市信息
* 如果緩存不存在,從 DB 中獲取城市信息,然后插入緩存
* @param id 用戶ID
* @return 用戶
*/
public User getById(int id) {
logger.info("獲取用戶start...");
// 從緩存中獲取用戶信息
String key = "user_" + id;
ValueOperations<String, User> operations = redisTemplate.opsForValue();
// 緩存存在
boolean hasKey = redisTemplate.hasKey(key);
if (hasKey) {
User user = operations.get(key);
logger.info("從緩存中獲取了用戶 id = " + id);
return user;
}
// 緩存不存在,從 DB 中獲取
User user = userMapper.selectById(id);
// 插入緩存
operations.set(key, user, 10, TimeUnit.SECONDS);
return user;
}
/**
* 更新用戶
* 如果緩存存在,刪除
* 如果緩存不存在,不操作
* @param user 用戶
*/
public void updateUser(User user) {
logger.info("更新用戶start...");
userMapper.updateById(user);
int userId = user.getId();
// 緩存存在,刪除緩存
String key = "user_" + userId;
boolean hasKey = redisTemplate.hasKey(key);
if (hasKey) {
redisTemplate.delete(key);
logger.info("更新用戶時候,從緩存中刪除用戶 >> " + userId);
}
}
/**
* 刪除用戶
* 如果緩存中存在,刪除
*/
public void deleteById(int id) {
logger.info("刪除用戶start...");
userMapper.deleteById(id);
// 緩存存在,刪除緩存
String key = "user_" + id;
boolean hasKey = redisTemplate.hasKey(key);
if (hasKey) {
redisTemplate.delete(key);
logger.info("刪除用戶時候,從緩存中刪除用戶 >> " + id);
}
}
}RedisTemplate 封裝了 RedisConnection,具有連接管理,序列化和 Redis 操作等功能。 還有專門針對 String 的模板對象 StringRedisTemplate。
Redis 操作視圖接口類用的是 ValueOperations ,對應的是 Redis String/Value 操作。
還有其他的操作視圖, ListOperations、SetOperations、ZSetOperations 和 HashOperations 。 ValueOperations 插入緩存是可以設(shè)置失效時間,這里設(shè)置的失效時間是 10 s。
然后寫個測試類測試運行看看效果:
@RunWith(SpringRunner.class)
@SpringBootTest(classes = Application.class)
@Transactional
public class UserServiceTest {
@Autowired
private UserService userService;
@Test
public void testCache() {
int id = new Random().nextInt(1000);
User user = new User(id, "admin", "admin");
userService.createUser(user);
User user1 = userService.getById(id); // 第1次訪問
assertEquals(user1.getPassword(), "admin");
User user2 = userService.getById(id); // 第2次訪問
assertEquals(user2.getPassword(), "admin");
user.setPassword("123456");
userService.updateUser(user);
User user3 = userService.getById(id); // 第3次訪問
assertEquals(user3.getPassword(), "123456");
userService.deleteById(id);
assertNull(userService.getById(id));
}
}運行SpringBoot集成測試,查看日志如下:
創(chuàng)建用戶start...
==> Preparing: INSERT INTO t_user ( id, username, `password` ) VALUES ( ?, ?, ? )
==> Parameters: 89(Integer), admin(String), admin(String)
<== Updates: 1
獲取用戶start...
Starting without optional epoll library
Starting without optional kqueue library
==> Preparing: SELECT id AS id,username,`password` FROM t_user WHERE id=?
==> Parameters: 89(Integer)
<== Total: 1
獲取用戶start...
從緩存中獲取了用戶 id = 89
更新用戶start...
==> Preparing: UPDATE t_user SET username=?, `password`=? WHERE id=?
==> Parameters: admin(String), 123456(String), 89(Integer)
<== Updates: 1
更新用戶時候,從緩存中刪除用戶 >> 89
獲取用戶start...
==> Preparing: SELECT id AS id,username,`password` FROM t_user WHERE id=?
==> Parameters: 89(Integer)
<== Total: 1
刪除用戶start...
==> Preparing: DELETE FROM t_user WHERE id=?
==> Parameters: 89(Integer)
<== Updates: 1
更新用戶時候,從緩存中刪除用戶 >> 89
獲取用戶start...
==> Preparing: SELECT id AS id,username,`password` FROM t_user WHERE id=?
==> Parameters: 89(Integer)
<== Total: 0
Rolled back transaction for test: [DefaultTestContext@6e20b53a testClass = UserServiceTest
Closing org.springframework.context.annotation.AnnotationConfigApplicationContext@503f91c3
{dataSource-1} closed
可以看出第一次取的時候,緩存未命中,會從DB中取數(shù)據(jù),而第2次取的時候緩存命中直接從緩存中取出來。
后面不管是更新和刪除,都會從緩存中刪除。再去取的時候緩存未命中,從DB中取最新的。
七、總結(jié)
本文歸納了常見的緩存更新的五種思路,其中先更新數(shù)據(jù)庫再刪緩存的思路是目前使用得最多的。
先刪緩存再更新數(shù)據(jù)庫因為出問題概率太大并沒有什么用。
第三到第五種思路在特定的應用場景下也有很多用途,比如先更新數(shù)據(jù)庫再更新緩存可以解決高并發(fā)下緩存未命中導致瞬時大量請求穿透到數(shù)據(jù)庫的問題。每一種方案也有其各自的優(yōu)點和不足,總而言之,沒有完美的方案,只有契合場景的更適合的方案。
到此這篇關(guān)于SpringBoot中Redis的緩存更新策略詳解的文章就介紹到這了,更多相關(guān)Redis的緩存更新策略內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
使用Spring Cloud Gateway實現(xiàn)代理服務(wù)
本文主要介紹了使用Spring Cloud Gateway實現(xiàn)代理服務(wù),文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2025-10-10
Java使用線程池批量處理數(shù)據(jù)操作具體流程
這篇文章主要給大家介紹了關(guān)于Java使用線程池批量處理數(shù)據(jù)操作的相關(guān)資料,Java多線程編程中線程池是一個非常重要的概念,線程池可以提高線程的復用率和任務(wù)調(diào)度的效率,尤其是當需要查詢大批量數(shù)據(jù)時,需要的朋友可以參考下2023-06-06
Java結(jié)構(gòu)型設(shè)計模式之享元模式示例詳解
享元模式(FlyWeight?Pattern),也叫蠅量模式,運用共享技術(shù),有效的支持大量細粒度的對象,享元模式就是池技術(shù)的重要實現(xiàn)方式。本文將通過示例詳細講解享元模式,感興趣的可以了解一下2022-09-09
教你怎么用java實現(xiàn)客戶端與服務(wù)器一問一答
這篇文章主要介紹了教你怎么用java實現(xiàn)客戶端與服務(wù)器一問一答,文中有非常詳細的代碼示例,對正在學習java的小伙伴們有非常好的幫助,需要的朋友可以參考下2021-04-04
java 通過cmd 調(diào)用命令啟動tomcat的操作
這篇文章主要介紹了java 通過cmd 調(diào)用命令啟動tomcat的操作,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2020-11-11

