SpringBoot中Redis的緩存更新策略詳解
常見的緩存更新策略
緩存一般是為了應(yīng)對(duì)高并發(fā)場(chǎng)景、緩解數(shù)據(jù)庫讀寫壓力,而將數(shù)據(jù)存儲(chǔ)在讀寫更快的某種存儲(chǔ)介質(zhì)中(如內(nèi)存),以加快讀取數(shù)據(jù)的速度。
緩存一般分為本地緩存(如java堆內(nèi)存緩存)、分布式緩存(如Redis)等。
既然是緩存,就意味著緩存中暫存的數(shù)據(jù)只是個(gè)副本,也就意味著需要保證副本和主數(shù)據(jù)之間的數(shù)據(jù)一致性,這就是接下來要分析的緩存的更新。
常見的緩存更新策略有:
- 先刪緩存,再更新數(shù)據(jù)庫
- 先更新數(shù)據(jù)庫,再刪緩存
- 先更新數(shù)據(jù)庫,再更新緩存
- read/write through
- 寫回。在更新數(shù)據(jù)的時(shí)候,只更新緩存,不更新數(shù)據(jù)庫,而我們的緩存會(huì)異步地批量更新數(shù)據(jù)庫
一、先刪緩存再更新數(shù)據(jù)庫
很明顯這個(gè)邏輯是有問題的,假設(shè)有兩個(gè)并發(fā)操作,一個(gè)操作更新、另一個(gè)操作查詢,更新操作刪除緩存后還沒來得及更新數(shù)據(jù)庫,此時(shí)另一個(gè)用戶發(fā)起了查詢操作,它因沒有命中緩存進(jìn)而從數(shù)據(jù)庫讀,此時(shí)第一個(gè)操作還沒到更新數(shù)據(jù)庫的階段,讀取到的是老數(shù)據(jù),接著寫到緩存中,導(dǎo)致緩存中數(shù)據(jù)變成臟數(shù)據(jù),并且會(huì)一直臟下去直到緩存過期或發(fā)起新的更新操作。
- 緩存初始值:A = 1
- 數(shù)據(jù)庫初始值:A = 1
- 緩存最終值:A = 1
- 數(shù)據(jù)庫最終值:A = 100
這個(gè)問題一般有兩種處理方式:
- 加鎖。鎖,意味著要去解決并發(fā)問題,那么也就意味著并發(fā)的處理會(huì)被串行的處理,性能自然會(huì)略低。
- 對(duì)key加上過期時(shí)間,把控好這個(gè)過期時(shí)間,不過業(yè)務(wù)上本身是具備容忍這個(gè)時(shí)間內(nèi)的數(shù)據(jù)不一致的問題方可。
- 降低出現(xiàn)這個(gè)問題的概率
二、先更新數(shù)據(jù)庫,再刪緩存
這是目前業(yè)界最常用的方案。雖然它同樣不夠完美,但問題發(fā)生的概率很小,它的讀流程和寫流程見下圖(圖片來源于網(wǎng)絡(luò),侵刪):
寫操作先更新數(shù)據(jù)庫,更新成功后使緩存失效。讀操作先讀緩存,緩存中讀到了則直接返回,緩存中讀不到再讀數(shù)據(jù)庫,之后再將數(shù)據(jù)庫數(shù)據(jù)加載到緩存中。
但它同樣也有問題,如下圖,查詢操作未命中緩存,接著讀數(shù)據(jù)庫老數(shù)據(jù)之后、寫緩存之前,此時(shí)另一個(gè)用戶發(fā)起了更新操作更新了數(shù)據(jù)庫并清了緩存,接著查詢操作將數(shù)據(jù)庫中老數(shù)據(jù)更新到緩存。這就導(dǎo)致緩存中數(shù)據(jù)變成臟數(shù)據(jù),并且會(huì)一直臟下去直到緩存過期或發(fā)起新的更新操作。
- 緩存初始值:空
- 數(shù)據(jù)庫初始值:A = 1
- 緩存最終值:A = 1
- 數(shù)據(jù)庫最終值:A = 100
為什么這種思路存在這么明顯的問題,卻還具有那么廣泛的應(yīng)用呢?因?yàn)檫@個(gè)case實(shí)際上出現(xiàn)的概率非常低,產(chǎn)生這個(gè)case需要具備如下4個(gè)條件:
- 讀操作讀緩存失效
- 有個(gè)并發(fā)的寫操作
- 寫操作比讀操作更快
- 讀操作早于寫操作進(jìn)入數(shù)據(jù)庫,晚于寫操作更新緩存
而實(shí)際上數(shù)據(jù)庫的寫操作會(huì)比讀操作慢得多,而且還要鎖表,而讀操作必需在寫操作前進(jìn)入數(shù)據(jù)庫操作,而又要晚于寫操作更新緩存,所有的這些條件都具備的概率基本并不大。并且即使出現(xiàn)這個(gè)問題還有一個(gè)緩存過期時(shí)間來自動(dòng)兜底。
三、先更新數(shù)據(jù)庫,再更新緩存
相對(duì)來講,理論上這種方式比先更新數(shù)據(jù)庫再刪緩存有著更高的讀性能,因?yàn)樗孪葴?zhǔn)備好數(shù)據(jù)。
但由于要更新數(shù)據(jù)庫和緩存兩塊數(shù)據(jù),所以它的寫性能就比較低,而且關(guān)鍵在于它也會(huì)出現(xiàn)臟數(shù)據(jù),如下圖,兩個(gè)并發(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)的操作由緩存自己代理了,所以,對(duì)于應(yīng)用層來說,就簡(jiǎn)單很多了。可以理解為,應(yīng)用認(rèn)為后端就是一個(gè)單一的存儲(chǔ),而存儲(chǔ)自己維護(hù)自己的Cache。
數(shù)據(jù)庫由緩存代理,緩存未命中時(shí)由緩存加載數(shù)據(jù)庫數(shù)據(jù)然后應(yīng)用從緩存讀,寫數(shù)據(jù)時(shí)更新完緩存后同步寫數(shù)據(jù)庫。應(yīng)用只感知緩存而不感知數(shù)據(jù)庫。
五、寫回
這種方式英文名叫Write Behind 又叫 Write Back。一些了解Linux操作系統(tǒng)內(nèi)核的同學(xué)對(duì)write back應(yīng)該非常熟悉,這不就是Linux文件系統(tǒng)的Page Cache的算法嗎?
是的,就是那個(gè)東西。這種模式是指在更新數(shù)據(jù)的時(shí)候,只更新緩存,不更新數(shù)據(jù)庫,而我們的緩存會(huì)異步地批量更新數(shù)據(jù)庫。
這種方式的問題在于數(shù)據(jù)不是強(qiáng)一致性的,而且可能會(huì)丟失(我們知道Unix/Linux非正常關(guān)機(jī)會(huì)導(dǎo)致數(shù)據(jù)丟失,就是因?yàn)檫@個(gè))。
另外,Write Back實(shí)現(xiàn)邏輯比較復(fù)雜,因?yàn)樗枰猼rack有哪數(shù)據(jù)是被更新了的,需要刷到持久層上。
操作系統(tǒng)的write back會(huì)在僅當(dāng)這個(gè)cache需要失效的時(shí)候,才會(huì)被真正持久起來,比如,內(nèi)存不夠了,或是進(jìn)程退出了等情況,這又叫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 中更新或者刪除商品詳情成功后,則從緩存中刪除對(duì)應(yīng)商品的詳情緩存
添加maven依賴
這里是以MyBatis做數(shù)據(jù)庫DAO操作,Redis做緩存操作。
SpringBoot 2開始默認(rèn)的Redis客戶端實(shí)現(xiàn)是 Lettuce
,同時(shí)你需要添加 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增強(qiáng)和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
對(duì)應(yīng)的配置類:
org.springframework.boot.autoconfigure.data.redis.RedisProperties
添加配置類
這里自定義RedisTemplate的配置類,主要是想使用Jackson替換默認(rèn)的序列化機(jī)制:
@Configuration public class RedisConfig { /** * redisTemplate 默認(rèn)使用JDK的序列化機(jī)制, 存儲(chǔ)二進(jìn)制字節(jié)碼, 所以自定義序列化類 * @param redisConnectionFactory redis連接工廠類 * @return RedisTemplate */ @Bean public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<>(); redisTemplate.setConnectionFactory(redisConnectionFactory); // 使用Jackson2JsonRedisSerialize 替換默認(rèn)序列化 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實(shí)現(xiàn)的DAO層,以及User實(shí)體類我就不貼在這里了,只貼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)建用戶 * 不會(huì)對(duì)緩存做任何操作 */ 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("更新用戶時(shí)候,從緩存中刪除用戶 >> " + 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("刪除用戶時(shí)候,從緩存中刪除用戶 >> " + id); } } }
RedisTemplate 封裝了 RedisConnection,具有連接管理,序列化和 Redis 操作等功能。 還有專門針對(duì) String 的模板對(duì)象 StringRedisTemplate。
Redis 操作視圖接口類用的是 ValueOperations
,對(duì)應(yīng)的是 Redis String/Value 操作。
還有其他的操作視圖, ListOperations、SetOperations、ZSetOperations 和 HashOperations 。 ValueOperations
插入緩存是可以設(shè)置失效時(shí)間,這里設(shè)置的失效時(shí)間是 10 s。
然后寫個(gè)測(cè)試類測(cè)試運(yùn)行看看效果:
@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)); } }
運(yùn)行SpringBoot集成測(cè)試,查看日志如下:
創(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
更新用戶時(shí)候,從緩存中刪除用戶 >> 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
更新用戶時(shí)候,從緩存中刪除用戶 >> 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
可以看出第一次取的時(shí)候,緩存未命中,會(huì)從DB中取數(shù)據(jù),而第2次取的時(shí)候緩存命中直接從緩存中取出來。
后面不管是更新和刪除,都會(huì)從緩存中刪除。再去取的時(shí)候緩存未命中,從DB中取最新的。
七、總結(jié)
本文歸納了常見的緩存更新的五種思路,其中先更新數(shù)據(jù)庫再刪緩存的思路是目前使用得最多的。
先刪緩存再更新數(shù)據(jù)庫因?yàn)槌鰡栴}概率太大并沒有什么用。
第三到第五種思路在特定的應(yīng)用場(chǎng)景下也有很多用途,比如先更新數(shù)據(jù)庫再更新緩存可以解決高并發(fā)下緩存未命中導(dǎo)致瞬時(shí)大量請(qǐng)求穿透到數(shù)據(jù)庫的問題。每一種方案也有其各自的優(yōu)點(diǎn)和不足,總而言之,沒有完美的方案,只有契合場(chǎng)景的更適合的方案。
到此這篇關(guān)于SpringBoot中Redis的緩存更新策略詳解的文章就介紹到這了,更多相關(guān)Redis的緩存更新策略內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Java實(shí)現(xiàn)的文件上傳下載工具類完整實(shí)例【上傳文件自動(dòng)命名】
這篇文章主要介紹了Java實(shí)現(xiàn)的文件上傳下載工具類,結(jié)合完整實(shí)例形式分析了java針對(duì)文件上傳下載操作的相關(guān)實(shí)現(xiàn)技巧,并且針對(duì)上傳文件提供了自動(dòng)命名功能以避免文件命名重復(fù),需要的朋友可以參考下2017-11-11SpringMVC Controller 返回值的可選類型詳解
本篇文章主要介紹了SpringMVC Controller 返回值的可選類型詳解 ,spring mvc 支持如下的返回方式:ModelAndView, Model, ModelMap, Map,View, String, void,有興趣的可以了解一下2017-05-05Java介紹多線程計(jì)算階乘實(shí)現(xiàn)方法
這篇文章主要為大家詳細(xì)介紹了Java多線程計(jì)算階乘的實(shí)現(xiàn),文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-06-06JAVA中整型數(shù)組、字符串?dāng)?shù)組、整型數(shù)和字符串 的創(chuàng)建與轉(zhuǎn)換的方法
本文介紹了Java中字符串、字符數(shù)組和整型數(shù)組的創(chuàng)建方法,以及它們之間的轉(zhuǎn)換方法,還詳細(xì)講解了字符串中的一些常用方法,如indexOf()方法,并通過一個(gè)算法題目來應(yīng)用這些知識(shí),感興趣的朋友一起看看吧2025-01-01Java數(shù)組優(yōu)點(diǎn)和缺點(diǎn)_動(dòng)力節(jié)點(diǎn)Java學(xué)院整理
本文給大家簡(jiǎn)單介紹下java數(shù)組的優(yōu)點(diǎn)和缺點(diǎn)知識(shí),需要的的朋友參考下吧2017-04-04