Vue + Spring Boot 项目实战(二十一):缓存的应用
重要鏈接:
「系列文章目錄」
「項目源碼(GitHub)」
本篇目錄
- 前言
- 一、緩存:工程思想的產物
- 二、Web 中的緩存
- 1.緩存的工作模式
- 2.緩存的常見問題
- 三、緩存應用實戰(zhàn)
- 1.Redis 與 Spring Data Redis
- 2.Redis 安裝
- 3.Spring Data Redis 配置
- 4.緩存實現(xiàn)
- 5.驗證
- 小結
- 附錄:關于單元測試
前言
大家好,這次過了三個月,再次創(chuàng)下新的記錄,大概鴿真的是人類的本性。
不過好在大多數(shù)讀者看這個教程的目的是做畢業(yè)設計,前面的內容都做出來再修修補補一下,老師大概率也不會為難你,所以更新慢也沒太大問題。
前兩天有讀者留言說我寫的越來越隨意了,但我的直觀感受是自己寫的越來越艱難。我瞄了一眼被吐槽的那篇文章的數(shù)據,貌似收藏和點贊數(shù)量都幾乎是最高的,看來可能只是覺得代碼講解少吧。
其實真的,貼代碼講代碼是最容易的,我可以這樣很輕松地寫三四十篇文章,但我覺得沒有意義。
在這兩個月里,我又重新系統(tǒng)地學了一遍軟件工程、瀏覽器工作原理,跟進網絡、軟件設計、產品方面的課程,并同時對一些技術進行了深入的了解,在這個基礎上,我才敢往后推進。
作為一個興趣使然的假程序員,我想我能告訴大家的最有價值的東西并不在技術細節(jié)上,畢竟我身在另一個賽道已經很久了。
我希望你們看到這一篇時,已經把前面說的各種操作、技巧、配置、字段都忘了,這些不重要。
咱們這個項目,缺乏商業(yè)價值,架構設計粗糙,代碼不夠整潔,編程風格混亂,依賴關系復雜,框架過度使用,算法不夠高效,安全防范缺失,開發(fā)過程隨意,缺少測試代碼,沒有監(jiān)控措施,缺失關鍵文檔,缺少版本控制。
你說它成功么?并不成功。但是失敗么?這套教程目前獲得了 70W+ 閱讀量,GitHub 倉庫將近千星,為我?guī)砹?1W+ 讀者里的百分之八十,所以我覺得并不失敗。而且正是由于我做到了現(xiàn)在,才知道原來一個項目要考慮這么多的因素,才總結的出如此之多的漏洞與不足。
唯一讓我感到遺憾的是沒有精力再從頭整理一遍,前面的文章還是有很多讓人困惑的地方。vue-cli 3.x 出了好久了,很多同學反映前面創(chuàng)建項目報錯,還有幾個氣的罵罵咧咧的,倒是可以理解,我雖然一直說人必須得學會自己解決問題,但畢竟如果入門都入不了的話也沒興趣解決問題對不對。
說了這么多,關鍵是想讓大家明白,我真的不是因為女朋友給我買了 switch,當上了海拉魯老流氓才混了這么長時間的,玩什么不是玩對不對,怪物獵人它不香么。
。。。
那么根據很久之前的計劃以及大家的反饋,這次我們來聊一聊緩存的使用。主要有以下幾個關注點:
- 緩存是什么?為什么需要緩存?
- 使用緩存需要注意哪些問題?
- Redis 是什么?
- 針對我們的項目,應該如何使用緩存?
一、緩存:工程思想的產物
緩存一詞最初主要指 CPU 與內存之間的高速靜態(tài)隨機存取存儲器(SRAM)。
我們知道,CPU 需要頻繁從內存中讀取指令、數(shù)據,但各個硬件的發(fā)展是不均衡的,我們當前使用的主流的動態(tài)隨機存儲存取器(DRAM)內存技術無法滿足 CPU 高速讀取的需求,成為制約計算機運行效率的重要因素之一。
而 SRAM 速度快,但體積大,成本高,就目前來講,一塊 16G 的 SRAM 可能比主板還大,且價格極高,因此短期之內不可能替代 DRAM 成為內存的主流技術選擇。
怎么辦呢,妥協(xié)一下,用塊小的 SRAM 放到 DRAM 的內存和 CPU 之間,不占什么地方,也不貴。那放上去有什么用?數(shù)據豈不是還要多經過一層 SRAM 才能到 CPU?這樣會變快嗎?
當然不會更快,但計算機的運行效率確實提升了,這是為什么?因為實際上在一段時間里,一小部分指令或數(shù)據會被 CPU 頻繁讀取,機智的人類通過算法,把這些指令、數(shù)據提取出來放到緩存里,這樣就能夠四兩撥千斤,取得明顯的效果。
你看,使用緩存不是必須的,如果我們能造出高速、便宜的存儲,就沒有這么多麻煩了。但在現(xiàn)實中,總會有各種各樣的不完美,機會總是稍縱即逝,如果去等待完美的條件,就難以向前邁進。
工程思想的核心,就是權衡與妥協(xié),接受不完美、不確定,通過各種手段把缺陷控制在可以容忍的范圍內,在有限的條件下盡可能地完成設定的目標、事業(yè)。
緩存是一種工程思想下自然而然的優(yōu)秀實踐,這一實踐逐漸被抽象成一種設計思路,在各種受到資源獲取開銷制約的場景下得到廣泛應用。
二、Web 中的緩存
在做項目的過程中,不知道你們有沒有感嘆過,一個平平無奇的應用,涉及的點實在是太多了。各個點之間需要銜接,要銜接就會有兩個層次的不均衡:
- 一是性能的不均衡,包括速率、吞吐量等,造成這種不均衡的原因包括軟件、硬件、網絡、協(xié)議、策略等、位置多個維度
- 二是數(shù)據本身活躍性的不均衡,有些數(shù)據會被頻繁傳遞,有些很久才被訪問一次
基于這兩個不平衡,誕生了各種緩存方案。比較常見的有以下幾種:
- 瀏覽器緩存,包括本地的頁面資源文件和 DNS 映射
- DNS 服務器上的緩存(IP - 域名映射)
- CDN,利用邊緣 Cache 服務器提高訪問速度
- ORM 框架提供的緩存,比如 Spring Data JPA 的持久化上下文
- 利用高性能非關系型數(shù)據庫(如 Redis)提供緩存服務,作為對關系型數(shù)據庫的補充
- 數(shù)據庫提供的緩存,比如 MySQL 自帶的查詢緩存,會把執(zhí)行語句與查詢結果以 K-V 形式緩存在內存中(由于該緩存命中率較低,不建議使用,且 8.0 版本已刪除此功能)
不得不說,對成熟的應用來說,一個普通的請求想過了緩存這關還真不容易。
看起來緩存還真是個好東西,到哪都好用。但多用了一個東西,畢竟還是會增加復雜性,復雜性越高越不好控制,我們設計一個軟件的架構,就是要讓它在夠用的前提下盡可能簡單,實現(xiàn)簡單、控制簡單、維護簡單。
1.緩存的工作模式
緩存的實際使用方法是有一些規(guī)律可循的,我們來簡單了解一下常見的幾種模式。
Cache-Aside:
最常見的模式,可以翻譯為旁路緩存或邊緣緩存。緩存作為數(shù)據庫(或存儲)的補充,數(shù)據的獲取策略是,如果緩存中存在,則從緩存獲取,如果不存在,則從數(shù)據庫獲取,并寫入緩存。
Read-Through:
把數(shù)據庫藏在緩存背后,一切請求交由緩存響應。也就是說,如果命中緩存,則直接從緩存獲取,如果沒有命中,則從數(shù)據庫中查詢,寫入緩存后再由緩存返回。
應用這種模式,寫入緩存的操作會阻塞請求的響應,我覺得其實大部分情況下沒有必要使用。
Write-Through:
對于需要動態(tài)更新數(shù)據的應用來說,僅僅通過讀操作觸發(fā)緩存更新肯定是不夠的,如果數(shù)據庫更新了而緩存遲遲沒有更新肯定說不過去。
當更新數(shù)據庫的數(shù)據時,也有兩種常見的操作緩存的模式。Write-Through 模式是:請求更新數(shù)據,如果該數(shù)據在緩存中存在,則先更新緩存,再更新數(shù)據庫。
Write-Back:
請求更新數(shù)據,更新緩存,至于數(shù)據庫什么時候更新,不一定,有機會再更新,可以攢一波再更新,有緩存在就行。
這種異步的方式一聽就有數(shù)據不一致的風險,但因為夠快,所以在一些要求高并發(fā)大吞吐量的系統(tǒng)中比較常見。其實高并發(fā)的一個核心解決方案就是緩存,高并發(fā)的復雜性很大程度上取決于緩存方案的復雜性。
這些方案具體怎么用其實還是看場景,要配置相應的策略防止出現(xiàn)一些問題。
2.緩存的常見問題
在使用緩存時,我們一般都會考慮以下幾個問題:
- 數(shù)據一致性問題,緩存的數(shù)據與數(shù)據庫由于各種原因產生差異
- 緩存穿透,明明已經用緩存了,還是有一堆請求殺到了數(shù)據庫。
- 緩存雪崩,一大批緩存同時過期,一大波請求趁虛而入,如同雪崩一般。
下面我們來聊一聊這三個問題如何應對。
數(shù)據一致性問題:
一個系統(tǒng),如果數(shù)據都是不變的,應用 Cache-Aside 模式,可以做到緩存中的數(shù)據永遠和數(shù)據庫中一致,需要考慮的就是緩存什么時候過期,或者緩存更新的算法,做到盡可能地找出熱點數(shù)據即可。
但大部分系統(tǒng)是要更新數(shù)據的,數(shù)據更新了緩存沒有及時更新,有時候沒有問題,但在一些場景下不能容忍,比如支付寶,你買了東西一看錢沒變,于是瘋狂買買買,后來突然一下錢全沒了,這誰頂?shù)淖Σ粚Α?/p>
于是我們在寫場景下更新緩存,采用先更數(shù)據庫再更緩存的模式,比如你買了個煎餅果子,支付寶實際余額從 100 變成了 90,你老婆同時在別的地方用你的支付寶又買了杯豆?jié){,實際余額變成 85,數(shù)據庫沒問題,但你買煎餅果子時緩存服務卡了一下子,更新操作發(fā)生在了豆?jié){事件的后面,你們倆回家一看查出來的余額是 90,以為白嫖了 5 塊錢,但其實還是假象。
其實數(shù)據一致性問題還是在并發(fā)這個范疇內,整體原則就是分析實際場景,盡可能選擇既高效又安全的方案。當然這并不是一件容易的事,如果容易就沒有那么多年薪百萬的架構師了。
緩存穿透:
引發(fā)緩存穿透的情形一般有兩種,一是大量查詢一個數(shù)據庫里也沒有的數(shù)據,這種數(shù)據正常不會被緩存,結果每次都要到數(shù)據庫里兜一圈。那我們可以設置一個規(guī)則,數(shù)據庫沒有的數(shù)據我們也緩存起來,值設置成空就行了。
另一種情形是,數(shù)據庫里有這個數(shù)據,之前從沒人查詢過,但突然有那么一瞬間來了一大波請求,緩存根本來不及反應,壓力就全都到了數(shù)據庫上。這種怎么辦?兩種辦法,一是限流,二是預判。
限流好理解,請求少了就反應的過來了。預判怎么預判?你怎么知道哪個數(shù)據會被頻繁訪問?
不好意思,一般還真的知道,一個數(shù)據突然被訪問的情況,一般是你自己搗鼓出來的什么幺蛾子,比如淘寶要搞雙十一,那有些數(shù)據一定會被突然頻繁訪問,這些數(shù)據當然能預判個八九不離十。在請求排山倒海般到來之前,先把它填充到緩存里就完事兒了。(這種做法通常稱為緩存預熱)
緩存雪崩:
其實本質上雪崩和穿透是一類問題,只是出現(xiàn)的階段不一樣,穿透是緩存已經穩(wěn)定建立起來了,雪崩是緩存突然同時過期了。當然還有一種情況,就是完全還沒有緩存的時候,一大波請求涌入。比如緩存沒做持久化,結果機房斷電了,重啟之后就是沒有緩存的。
解決方法仍然是限流和緩存預熱。其實這些名詞也是沒意思,奈何總是有人會問,有人會考。
三、緩存應用實戰(zhàn)
了解了緩存的基本概念和應用模式,我們來整點實際操作。前端頁面的本地緩存已經由瀏覽器實現(xiàn)了,我們不用管,主要操心一下后端。
你看,前端后端都有緩存,但各自解決問題的邊界是不一樣的,前端緩存應對的是靜態(tài)頁面資源的訪問,本地緩存可以更具體地說是同一用戶(終端)的多次訪問,而后端緩存更多的考慮多個用戶的多次訪問,面向的資源主要是數(shù)據庫里的數(shù)據。
對于我們項目的后端呢,我想了半天,覺得沒有需要的地方,我們這么簡單一應用,也沒用戶,也沒流量,要啥自行車啊?
但為了學習嘛,就強行假設有很多人用咱們做的這個破網站吧。那哪些場景用的比較多,數(shù)據庫壓力比較大呢?應該是前臺的圖書信息和文章兩個部分。
那么用什么來實現(xiàn)緩存呢?目前最常見的做法是用 Redis 來實現(xiàn)。
1.Redis 與 Spring Data Redis
首先我們要記住,Redis 和 MySQL 一樣,是一個數(shù)據庫管理系統(tǒng),人家不是就為了做緩存的。
Redis ≠ 緩存 ,只是由于這玩意兒現(xiàn)在訪問速度快,但又不能完全替代關系型數(shù)據庫,所以確實適合用來做關系型數(shù)據庫的緩存,都是形勢所迫,說不定哪一天就翻身了。
我們要在應用中操縱這個數(shù)據庫,自然也需要與關系型數(shù)據庫相似的訪問方法。MySQL 我們用 Spring Data JPA,Redis 我們就用 Spring Data Redis。
其實在此之前,Java 訪問 Redis 主要是通過 Jedis 和 Lettuce 兩種由不同團隊開發(fā)的客戶端(提供訪問、操作所需的 API),Jedis 比較原生,Lettuce 提供的能力更加全面。
Spring Data Redis 是在 Lettuce 的基礎上做了一些封裝,與 Spring 生態(tài)更加貼合,使用起來也更簡便。
2.Redis 安裝
官方下載地址:https://redis.io/download
正常 Redis 只提供 Linux 版本,Windows 版本由微軟提供,版本只到 3.2.100,在 2016 年以后就沒有再更新過。下載地址為:https://github.com/microsoftarchive/redis/releases
Linux 下可以用 docker 安裝鏡像,更下方便。我下載的是 Windows 版,但不推薦大家使用。
3.Spring Data Redis 配置
這部分內容可以參考 @MacroZheng 的 「Spring Data Redis 最佳實踐!」
首先是在 pom.xml 中添加依賴
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- redis 連接池 --> <dependency><groupId>org.apache.commons</groupId><artifactId>commons-pool2</artifactId> </dependency>再在 application.properties 中配置一些參數(shù),常用的有以下幾種:
spring.redis.host=localhost spring.redis.port=6379 # Redis 數(shù)據庫索引(默認為 0) spring.redis.database=0 # Redis 服務器連接密碼(默認為空) spring.redis.password= #連接池最大連接數(shù)(使用負值表示沒有限制) spring.redis.lettuce.pool.max-active=8 # 連接池最大阻塞等待時間(使用負值表示沒有限制) spring.redis.lettuce.pool.max-wait=-1 # 連接池中的最大空閑連接 spring.redis.lettuce.pool.max-idle=8 # 連接池中的最小空閑連接 spring.redis.lettuce.pool.min-idle=0 # 連接超時時間(毫秒) spring.redis.timeout=2000 # redis 只用作緩存,不作為 repository spring.data.redis.repositories.enabled=falseJava 中的對象存儲進 Redis 之前需要進行序列化,默認為字節(jié)數(shù)組。我們?yōu)榱朔奖憬馕?#xff0c;可以將其配置為 JSON 格式。可以創(chuàng)建一個 RedisConfig 類,代碼如下:
package com.gm.wj.config;import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.PropertyAccessor; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.cache.annotation.CachingConfigurerSupport; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.cache.RedisCacheConfiguration; import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.cache.RedisCacheWriter; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer;import java.time.Duration;@EnableCaching @Configuration public class RedisConfig extends CachingConfigurerSupport {public static final String REDIS_KEY_DATABASE="wj";@Beanpublic RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {RedisSerializer<Object> serializer = redisSerializer();RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();redisTemplate.setConnectionFactory(redisConnectionFactory);redisTemplate.setKeySerializer(new StringRedisSerializer());// 設置 redisTemplate 的序列化器redisTemplate.setValueSerializer(serializer);redisTemplate.setHashKeySerializer(new StringRedisSerializer());redisTemplate.setHashValueSerializer(serializer);redisTemplate.afterPropertiesSet();return redisTemplate;}@Beanpublic RedisSerializer<Object> redisSerializer() {//創(chuàng)建JSON序列化器Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);ObjectMapper objectMapper = new ObjectMapper();objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);objectMapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);serializer.setObjectMapper(objectMapper);return serializer;}@Beanpublic RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {RedisCacheWriter redisCacheWriter = RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory);//設置Redis緩存有效期為1天RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig().serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer())).entryTtl(Duration.ofDays(1));return new RedisCacheManager(redisCacheWriter, redisCacheConfiguration);} }上面的文章里介紹了如何通過注解使用緩存,我們一般希望能夠更靈活地運用,因此通常選用 RedisTemplate 來實現(xiàn)自由操作。
RedisTemplate 是 Spring Data Redis 提供的一個完成 Redis 操作、異常轉換和序列化的類,我們可以類比 JdbcTemplate 去使用它。官方文檔地址:
docs.spring.io - RedisTemplate
4.緩存實現(xiàn)
下面我們來嘗試實現(xiàn)為項目的圖書館頁面和筆記本(文章)頁面加上緩存。首先編寫一個 Service 類,封裝我們將要用到的操作。
package com.gm.wj.redis;import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.*; import org.springframework.stereotype.Service;import java.util.Set; import java.util.concurrent.TimeUnit;@Service public class RedisService {@Autowiredprivate RedisTemplate<String, Object> redisTemplate;// 設置帶過期時間的緩存public void set(String key, Object value, long time) {redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);}// 設置緩存public void set(String key, Object value) {redisTemplate.opsForValue().set(key, value);}// 根據 key 獲得緩存public Object get(String key) {return redisTemplate.opsForValue().get(key);}// 根據 key 刪除緩存public Boolean delete(String key) {return redisTemplate.delete(key);}// 根據 keys 集合批量刪除緩存public Long delete(Set<String> keys) {return redisTemplate.delete(keys);}// 根據正則表達式匹配 keys 獲取緩存public Set<String> getKeysByPattern(String pattern) {return redisTemplate.keys(pattern);} }注意這里存儲對象均被視為 Object,如果存儲對象為 String,可以進一步使用 StringRedisTemplate 來實現(xiàn)更貼合字符串的處理方法。
接下來,就可以在具體的 Service 里添加緩存的處理邏輯。
BookService:
針對獲取圖書列表的請求,可以先根據設置的 key 查詢緩存,如果有則直接從緩存里獲取,如果沒有則從數(shù)據庫查詢并寫入緩存。
public List<Book> list() {List<Book> books;String key = "booklist";Object bookCache = redisService.get(key);if (bookCache == null) {Sort sort = new Sort(Sort.Direction.DESC, "id");books = bookDAO.findAll(sort);redisService.set(key, books);} else {books = CastUtils.objectConvertToList(bookCache, Book.class);}return books; }注意從緩存拿回來的是 Object ,我們需要編寫一個方法把它轉換為 List:
public static <T> List<T> objectConvertToList(Object obj, Class<T> clazz) {List<T> result = new ArrayList<T>();if(obj instanceof List<?>){for (Object o : (List<?>) obj){result.add(clazz.cast(o));}return result;}return null; }如果我們對圖書的信息進行了修改,需要對緩存也進行相應的修改。因為我們緩存的粒度是整個列表,所以在對數(shù)據庫進行增刪改操作時可以直接將書籍列表的緩存全部清除。
這樣其實避免了上面說的緩存更新順序不一致的問題,我就硬刪除,先刪后刪緩存里結果都一樣。
public void addOrUpdate(Book book) {redisService.delete("booklist");bookDAO.save(book);}public void deleteById(int id) {redisService.delete("booklist");bookDAO.deleteById(id);}問題還是來了,即使在理想的情況下,數(shù)據庫和緩存的操作都不會失敗,假如我在后臺刪了一本書,緩存被清除了,數(shù)據庫還沒來得及更新,這個節(jié)骨眼上有用戶訪問了一下,結果又拿到了舊的數(shù)據還寫入了緩存,那下次清除緩存前用戶拿到的全是舊數(shù)據。
如果我先改數(shù)據庫再刪緩存呢?
public void addOrUpdate(Book book) {bookDAO.save(book);redisService.delete("booklist");}public void deleteById(int id) {bookDAO.deleteById(id);redisService.delete("booklist");}還是不妥,雖然前面沒刪緩存,但假如緩存先自然失效了,用戶的訪問還是會觸發(fā)緩存寫入操作,此后極短時間內我們又更新了書籍,這兩個事件是異步的,我們無法得知緩存寫入何時能夠完成,如果是在緩存刪除之后,那緩存中就還是會長期存在舊的數(shù)據。
此外,如果前面不刪緩存,有那么一丟丟的時間,數(shù)據庫更新了而緩存沒有更新,用戶還是會拿到舊的數(shù)據。
前后刪都不行,怎么辦?
又有人提出了 “延時雙刪” 策略,就是先清除緩存,在更新數(shù)據庫后,等一段時間,再去第二次執(zhí)行刪除操作。這樣,用戶拿到舊庫的數(shù)據,并且在第二次刪除緩存之后才觸發(fā)緩存更新的概率就比較低。這個時間怎么把握呢?可以測試、估算,沒有一個準數(shù)。這個過程最好設置成異步的,以免阻塞正常操作。
在這個等待的過程中,還是可能出現(xiàn)有用戶讀到舊數(shù)據的緩存的情況,腦殼疼。。。
現(xiàn)實中還有很多更合理高效的方案,但我估計都不那么完美,我們只能根據實際需要,在合理的成本范圍內做出選擇。
OK,最后再貼一下為文章設置緩存的代碼:
package com.gm.wj.service;import com.gm.wj.dao.JotterArticleDAO; import com.gm.wj.entity.JotterArticle; import com.gm.wj.redis.RedisService; import com.gm.wj.util.MyPage; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service;import java.util.Set;@Service public class JotterArticleService {@AutowiredJotterArticleDAO jotterArticleDAO;@AutowiredRedisService redisService;// MyPage 是自定義的 Spring Data JPA Page 對象的替代public MyPage list(int page, int size) {MyPage<JotterArticle> articles;// 用戶訪問列表頁面時按頁緩存文章String key = "articlepage:" + page;Object articlePageCache = redisService.get(key);if (articlePageCache == null) {Sort sort = new Sort(Sort.Direction.DESC, "id");Page<JotterArticle> articlesInDb = jotterArticleDAO.findAll(PageRequest.of(page, size, sort));articles = new MyPage<>(articlesInDb);redisService.set(key, articles);} else {articles = (MyPage<JotterArticle>) articlePageCache;}return articles;}public JotterArticle findById(int id) {JotterArticle article;// 用戶訪問具體文章時緩存單篇文章,通過 id 區(qū)分String key = "article:" + id;Object articleCache = redisService.get(key);if (articleCache == null) {article = jotterArticleDAO.findById(id);redisService.set(key, article);} else {article = (JotterArticle) articleCache;}return article;}public void addOrUpdate(JotterArticle article) {jotterArticleDAO.save(article);// 刪除當前選中的文章和所有文章頁面的緩存redisService.delete("article" + article.getId());Set<String> keys = redisService.getKeysByPattern("articlepage*");redisService.delete(keys);}public void delete(int id) {jotterArticleDAO.deleteById(id);// 刪除當前選中的文章和所有文章頁面的緩存redisService.delete("article:" + id);Set<String> keys = redisService.getKeysByPattern("articlepage*");redisService.delete(keys);} }這里我就直接后刪緩存了,不多費勁。這里注意 Spring Data JPA 的 Page 對象無法被反序列化,因為它的實現(xiàn)類 PageImpl 沒有空參構造器。因此我們需要自定義一個 MyPage 類:
package com.gm.wj.util;import org.springframework.data.domain.Page; import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; import java.util.Iterator; import java.util.List;public class MyPage<T> implements Iterable<T>, Serializable {private static final long serialVersionUID = -3720998571176536865L;private List<T> content = new ArrayList<>();private long totalElements;private int pageNumber;private int pageSize;private boolean first;private boolean last;private boolean empty;private int totalPages;private int numberOfElements;public MyPage() {}//只用把原來的page類放進來即可public MyPage(Page<T> page) {this.content = page.getContent();this.totalElements = page.getTotalElements();this.pageNumber = page.getPageable().getPageNumber();this.pageSize = page.getPageable().getPageSize();this.numberOfElements = page.getNumberOfElements();}//是否有前一頁public boolean hasPrevious() {return getPageNumber() > 0;}//是否有下一頁public boolean hasNext() {return getPageNumber() + 1 < getTotalPages();}//是否第一頁public boolean isFirst() {return !hasPrevious();}//是否最后一頁public boolean isLast() {return !hasNext();}//獲取內容public List<T> getContent() {return Collections.unmodifiableList(content);}//設置內容public void setContent(List<T> content) {this.content = content;}//是否有內容public boolean hasContent() {return getNumberOfElements() > 0;}//獲取單頁大小public int getPageSize() {return pageSize;}//設置單頁大小public void setPageSize(int pageSize) {this.pageSize = pageSize;}//獲取全部元素數(shù)目public long getTotalElements() {return totalElements;}//設置全部元素數(shù)目public void setTotalElements(long totalElements) {this.totalElements = totalElements;}//設置是否第一頁public void setFirst(boolean first) {this.first = first;}// 設置是否最后一頁public void setLast(boolean last) {this.last = last;}//獲取當前頁號public int getPageNumber() {return pageNumber;}//設置當前頁號public void setPageNumber(int pageNumber) {this.pageNumber = pageNumber;}//獲取總頁數(shù)public int getTotalPages() {return getPageSize() == 0 ? 1 : (int) Math.ceil((double) totalElements / (double) getPageSize());}//設置總頁數(shù)public void setTotalPages(int totalPages) {this.totalPages = totalPages;}//獲取單頁元素數(shù)目public int getNumberOfElements() {return numberOfElements;}//設置單頁元素數(shù)目public void setNumberOfElements(int numberOfElements) {this.numberOfElements = numberOfElements;}//判斷是否為空public boolean isEmpty() {return !hasContent();}//設置是否為空public void setEmpty(boolean empty) {this.empty = empty;}//迭代器@Overridepublic Iterator<T> iterator() {return getContent().iterator();} }唉,還是這么多代碼,到時候報錯多了又得有人噴我,幸虧我一直比較皮實,心態(tài)還算可以。
5.驗證
以 Windows 為例,打開緩存服務(cmd 進入緩存文件夾,執(zhí)行 redis-sever),顯示界面如下:
打開項目,可以在 application.properties 中配置一條語句,顯示后端執(zhí)行的 sql 命令:
spring.jpa.properties.hibernate.show_sql=true
運行項目,訪問文章頁面、圖書館頁面、點擊最上面的文章。
這時,控制臺顯示了一些語句
可以再啟動一個終端,進入 redis 目錄輸入 redis-cli 打開客戶端,輸入 keys * 查看保存的鍵
可以看到,第 1 頁(JPA 分頁默認第一頁為 0)、圖書列表、第三篇文章(逆序第一篇)被添加進了 Redis 里。
之后,我們再刷新圖書館、筆記本頁面或者訪問第一篇文章時,sql 語句就不會再顯示了。
不知道大家是否還記得前面提到過的 JPA 持久化上下文,實際上,就算輸出了這些指令,也不會真的去查詢數(shù)據庫,而是復用之前已經查詢到的對象。那為什么還要用 Redis 呢?其實一開始我也說了,真的沒有必要。
當然,這只是因為我們的項目結構比較簡單。假如我們想把緩存服務部署在別的服務器上,持久化上下文就無法生效了。或者我想使用更靈活的算法,比如只緩存比較活躍的數(shù)據,而不是來者不拒,就還是需要有更強大的能力支持。
小結
這篇文章說的東西比較多,稍微做個總結吧。
不用記得太多,下面幾句話就夠了:
- 緩存是工程思想的產物,是解決不對稱問題的一種優(yōu)秀實踐,并得到了廣泛應用
- 緩存的引入會提高項目復雜度,要綜合取舍使用方案
- Redis 不是緩存,但可以實現(xiàn)緩存服務
在寫新的內容之外,我準備背地里偷偷優(yōu)化一下前面的文章,不過你們都看到這兒了,也沒必要回頭再去找哪些地方改了,向前看就好了。
之前開玩笑說這個教程能寫到退休,但我仔細想了一下,還是盡快地收個尾吧,都兩年了,再過去兩年,可能教程里用的技術都過時百分之八十了。
這個系列完結后,我會多寫一些偏理論的文章。我干的工作比較雜,比起深入鉆研一個技術點,可能還是更適合幫助大家了解一個行業(yè)、一個生態(tài)的全貌。
各位放心,我不會再鴿這么長時間了,那倆垃圾游戲已經被我打通關了,DLC 什么的等我漲工資了再買吧。
2020 都不容易,送給大家一句話,要敢于做困難事,堅持做困難事,困難是人進步的源泉,總有一天你會發(fā)現(xiàn),自己變禿了,也變強了。
總有人覺得一年不如一年,但我始終認為我們就身處在最好的時代,風起云涌,無限可能。
上一篇:Vue + Spring Boot 項目實戰(zhàn)(二十):前端優(yōu)化實戰(zhàn)
附錄:關于單元測試
本來按照計劃下一篇要講一講單元測試,但是我發(fā)現(xiàn)白卷項目在可測試性上一塌糊涂,在寫下一篇文章前折騰了兩天,還是沒能讓單元測試的代碼符合我自己的審美。(嗯,這個理由我認可了)
那這里我們先打個引子,講一講單元測試是什么、有什么用以及重點在哪里。其它的以后再說吧,不然又要托更了。
首先,關于測試,比較常見的分類方式有:
- 按開發(fā)周期:單元測試、集成測試、系統(tǒng)測試、驗收測試
- 按實施者:α、β、第三方
- 按是否執(zhí)行代碼:靜態(tài)測試、動態(tài)測試
- 按代碼可見性:黑盒測試、白盒測試、灰盒測試
- 按是否自動執(zhí)行:自動化測試、手工測試
- 按測試對象:性能測試、安全測試、兼容性測試、文檔測試、易用性測試、業(yè)務測試、界面測試、可安裝性測試
在這個框架之下,我們所說的單元測試,是指開發(fā)人員在代碼編寫階段同步實施的動態(tài)白盒測試,測試內容一般是業(yè)務邏輯和安全性,既可以手動編寫測試代碼,也可以借助一些自動化工具。
作為程序員,寫單元測試是一件煩躁的事情。因為寫單元測試,看起來大部分都是無用功,會嚴重拖慢開發(fā)的進程。
有一種常見的想法就是,代碼質量和工作效率不可兼得,我知道測試有用,但現(xiàn)在就是著急出成果,沒辦法,不得不先放下。
這種想法乍一看沒什么問題,我也時常覺得推進工作需要權衡和妥協(xié),但實際上,代碼質量與工作效率并不矛盾,這種認識是一種誤區(qū)。如果不注重代碼質量,也許只是起步快一點,但在通往終點的路上,一定困難重重。
不只是開發(fā),其實在任何工作中都是這樣一個規(guī)律,那就是問題發(fā)現(xiàn)的越早,解決問題需要的成本就越低。我們雖然無法做到萬無一失,但一定要在心里有一桿秤,什么樣的風險是要盡量早些規(guī)避的。評估風險的大小有一個簡單的公式,即:
風險 = 損失 * 概率
其實單元測試的重點不是編寫測試代碼的套路,它真正的功夫體現(xiàn)在測試用例的設計上。而設計全面且高效的測試用例并不那么容易,需要有相當全面的知識,老道的經驗,還要講究一些方法論,比如等價類劃分、邊界值分析等。
我有一個朋友,有一天寫了一個登錄功能,跑起來一試,賬號 admin,密碼 123 ,進去了,歐了。
過了一天,覺得不對勁,試了試賬號 admin,密碼 456,也進去了,他說哦,原來是昨天,有個地方寫錯了,改了就完了。
又過了兩天,又覺得不對,試了試賬號 admin,密碼不填,又進去了,好吧,大意了,再來一遍。
終于覺得沒有問題了,代碼到了測試手里。沒多久突然有個同事問他發(fā)生甚么事了,給他發(fā)來幾張截圖,他一看代碼被返工了,老大臉色鐵青地殺過來一頓臭罵。兩分多鐘以后,測試跑過來說對不起對不起,我不是找茬,我就隨便一試。他可不是隨便一試啊,暴力破解、SQL 注入、XSS,訓練有素,后來他說他學過三四年滲透,看來是有備而來。
后來,我這個朋友沒事就跑到測試旁邊,觀察他們的用例,模仿他們的做法,漸漸地犯的錯誤就越來越少了。
實際上當你時刻把單元測試放在心上,即使這個測試并沒有執(zhí)行,也會極大地減少犯錯的概率,提高代碼的質量。
上一篇:Vue + Spring Boot 項目實戰(zhàn)(二十):前端優(yōu)化實戰(zhàn)
下一篇:Vue + Spring Boot 項目實戰(zhàn)(二十二):生產環(huán)境初步搭建
總結
以上是生活随笔為你收集整理的Vue + Spring Boot 项目实战(二十一):缓存的应用的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 人类首次捕获到反物质 500克能量可超过
- 下一篇: matlab滤波器fdatool,各种类