SpringBoot中Redis的緩存更新策略詳解
常見的緩存更新策略
緩存一般是為了應(yīng)對高并發(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ù),接著寫到緩存中,導(dǎo)致緩存中數(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ù)更新到緩存。這就導(dǎo)致緩存中數(shù)據(jù)變成臟數(shù)據(jù),并且會一直臟下去直到緩存過期或發(fā)起新的更新操作。
- 緩存初始值:空
- 數(shù)據(jù)庫初始值:A = 1
- 緩存最終值:A = 1
- 數(shù)據(jù)庫最終值:A = 100
為什么這種思路存在這么明顯的問題,卻還具有那么廣泛的應(yīng)用呢?因為這個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)的操作由緩存自己代理了,所以,對于應(yīng)用層來說,就簡單很多了。可以理解為,應(yīng)用認為后端就是一個單一的存儲,而存儲自己維護自己的Cache。
數(shù)據(jù)庫由緩存代理,緩存未命中時由緩存加載數(shù)據(jù)庫數(shù)據(jù)然后應(yīng)用從緩存讀,寫數(shù)據(jù)時更新完緩存后同步寫數(shù)據(jù)庫。應(yīng)用只感知緩存而不感知數(shù)據(jù)庫。
五、寫回
這種方式英文名叫Write Behind 又叫 Write Back。一些了解Linux操作系統(tǒng)內(nèi)核的同學(xué)對write back應(yīng)該非常熟悉,這不就是Linux文件系統(tǒng)的Page Cache的算法嗎?
是的,就是那個東西。這種模式是指在更新數(shù)據(jù)的時候,只更新緩存,不更新數(shù)據(jù)庫,而我們的緩存會異步地批量更新數(shù)據(jù)庫。
這種方式的問題在于數(shù)據(jù)不是強一致性的,而且可能會丟失(我們知道Unix/Linux非正常關(guān)機會導(dǎo)致數(shù)據(jù)丟失,就是因為這個)。
另外,Write Back實現(xiàn)邏輯比較復(fù)雜,因為他需要track有哪數(shù)據(jù)是被更新了的,需要刷到持久層上。
操作系統(tǒng)的write back會在僅當這個cache需要失效的時候,才會被真正持久起來,比如,內(nèi)存不夠了,或是進程退出了等情況,這又叫l(wèi)azy write。
六、以第一種方式舉例
- 失效:應(yīng)用程序先從cache取數(shù)據(jù),沒有得到,則從數(shù)據(jù)庫中取數(shù)據(jù),成功后,放到緩存中。
- 命中:應(yīng)用程序從cache中取數(shù)據(jù),取到后返回。
- 更新:先把數(shù)據(jù)存到數(shù)據(jù)庫中,成功后,再讓緩存失效。
大致流程如下:
獲取商品詳情舉例
- 從商品 Cache 中獲取商品詳情,如果存在,則返回獲取 Cache 數(shù)據(jù)返回。
- 如果不存在,則從商品 DB 中獲取。獲取成功后,將數(shù)據(jù)存到 Cache 中。則下次獲取商品詳情,就可以從 Cache 就可以得到商品詳情數(shù)據(jù)。
- 從商品 DB 中更新或者刪除商品詳情成功后,則從緩存中刪除對應(yīng)商品的詳情緩存
添加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
對應(yīng)的配置類:
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
,對應(yīng)的是 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ù)庫因為出問題概率太大并沒有什么用。
第三到第五種思路在特定的應(yīng)用場景下也有很多用途,比如先更新數(shù)據(jù)庫再更新緩存可以解決高并發(fā)下緩存未命中導(dǎo)致瞬時大量請求穿透到數(shù)據(jù)庫的問題。每一種方案也有其各自的優(yōu)點和不足,總而言之,沒有完美的方案,只有契合場景的更適合的方案。
到此這篇關(guān)于SpringBoot中Redis的緩存更新策略詳解的文章就介紹到這了,更多相關(guān)Redis的緩存更新策略內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Java實現(xiàn)的文件上傳下載工具類完整實例【上傳文件自動命名】
這篇文章主要介紹了Java實現(xiàn)的文件上傳下載工具類,結(jié)合完整實例形式分析了java針對文件上傳下載操作的相關(guān)實現(xiàn)技巧,并且針對上傳文件提供了自動命名功能以避免文件命名重復(fù),需要的朋友可以參考下2017-11-11SpringMVC Controller 返回值的可選類型詳解
本篇文章主要介紹了SpringMVC Controller 返回值的可選類型詳解 ,spring mvc 支持如下的返回方式:ModelAndView, Model, ModelMap, Map,View, String, void,有興趣的可以了解一下2017-05-05JAVA中整型數(shù)組、字符串數(shù)組、整型數(shù)和字符串 的創(chuàng)建與轉(zhuǎn)換的方法
本文介紹了Java中字符串、字符數(shù)組和整型數(shù)組的創(chuàng)建方法,以及它們之間的轉(zhuǎn)換方法,還詳細講解了字符串中的一些常用方法,如indexOf()方法,并通過一個算法題目來應(yīng)用這些知識,感興趣的朋友一起看看吧2025-01-01Java數(shù)組優(yōu)點和缺點_動力節(jié)點Java學(xué)院整理
本文給大家簡單介紹下java數(shù)組的優(yōu)點和缺點知識,需要的的朋友參考下吧2017-04-04