【項目學習】穀粒商城學習記錄4 - 高級篇(性能壓測 & 緩存)
一、性能壓測
1、Jmeter
(1) Jmeter安裝
- jmeter官網download頁
- 選擇支持java 8+的.zip版本下載,解壓後打開bin/jemter.bat, 並修改語言
- 選擇支持java 8+的.zip版本下載,解壓後打開bin/jemter.bat, 並修改語言
2、Nginx動靜分離
- 爲什麼要動靜分離?
- 未分離的項目靜態資源放在後端,無論是動態請求還是靜態請求都會來到後臺,這極大的損耗了後臺Tomcat性能(大部分性能都用來處理靜態請求)
動靜分離後,後臺只會處理動態請求,而靜態資源直接由nginx返回。 - nginx.conf 配置文件,Windows和Linux有點區別
注意:匹配靜態資源時,是找/static/,然後將請求在
D:/tools/Nginx/nginx-1.22.0/html
目錄下面找,如:請求http://gulimall.com/static/index/img/img_09.png
經過nginx轉發就變成在路徑D:/tools/Nginx/nginx-1.22.0/html/static/index/img/img_09.png
。worker_processes 1; events { worker_connections 1024; } http { include mime.types; default_type application/octet-stream; client_max_body_size 1024m; sendfile on; keepalive_timeout 65; upstream gulimall { server 本地ip:88; } server { listen 80; #監聽此端口 server_name gulimall.com; #監聽此域名 location /static/ { root D:/tools/Nginx/nginx-1.22.0/html; } location / { proxy_set_header Host $host; proxy_pass http://gulimall; } } }
- 未分離的項目靜態資源放在後端,無論是動態請求還是靜態請求都會來到後臺,這極大的損耗了後臺Tomcat性能(大部分性能都用來處理靜態請求)
二、緩存
1、背景
(1) 爲什麼需要緩存
- 頻繁的請求數據造成了效率的下降,尤其是許多不經常改變的數據。引入緩存,能讓數據庫更關注於數據持久化,同時減少請求次數。
- 哪些數據適合用緩存?
- 對即時性和數據一致性要求不高
- 訪問量大且更新頻率低的數據(讀多,寫少)
- 緩存維護和請求流程:
注意: 在開發中, 凡是放入緩存中的數據我們都應該指定過期時間, 使其可以在系統即使沒有主動更新數據也能自動觸發數據加載進緩存的流程。 避免業務崩潰導致的數據永久不一致問題
(2) 本地緩存與分佈式緩存
-
本地緩存可以用map實現
-
本地緩存存在的問題:
- 集羣下本地緩存不共享, 存在於jvm中
- 數據一致性不能維護 不同機器的緩存在分佈式情況下不能維護一致性
-
解決方法:分佈式緩存(Redis作爲緩存中間件)
- redis將緩存從服務集羣中抽離,保證了數據一致性
- redis通過建立集羣+分片操作,提高緩存容量
2、springboot整合redis
(1) 添加依賴並配置
- 在product模塊的pom.xml文件中引入:
<!-- redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
- 添加配置信息:
spring: redis: host: 101.201.39.44 port: 20002
- 測試
@Autowired StringRedisTemplate stringRedisTemplate; @Test public void teststringRedisTemplate() { //hello world ValueOperations<String, String> ops = stringRedisTemplate.opsForValue(); //保存 ops.set("hello", "world_" + UUID.randomUUID().toString()); //查詢 String hello = ops.get("hello"); System.out.println("之前保存的數據是" + hello); }
- 測試結果:
3、緩存使用-改造三級分類業務
(1) 重寫方法getCatalogJson
-
爲CategoryServiceIpml類添加自動注入的redisTemplate
@Autowired private StringRedisTemplate redisTemplate;
-
將之前實現的
getCatalogJson
重命名爲getCatalogJsonFromDb
,並刪除@Override -
重寫新的
getCatalogJson
@Override public Map<String, List<Catalog2Vo>> getCatalogJson() { //給緩存中放json字符串,拿出的json字符串,還能逆轉爲可用的對象類型: 【序列化與反序列化】 //1、加入緩存邏輯 //JSON是跨語言,跨平臺兼容的 String catalogJSON = redisTemplate.opsForValue().get("catalogJSON"); if(StringUtils.isEmpty(catalogJSON)) { //2、緩存中沒有,就查詢數據庫 Map<String, List<Catalog2Vo>> catalogJsonFromDb = getCatalogJsonFromDb(); //3、查到的數據再放入緩存,將對象轉爲json放在緩存中 String s = JSON.toJSONString(catalogJsonFromDb); redisTemplate.opsForValue().set("catalogJSON", s); return catalogJsonFromDb; } //轉爲我們指定的對象 Map<String, List<Catalog2Vo>> result = JSON.parseObject(catalogJSON, new TypeReference<Map<String, List<Catalog2Vo>>>(){}); return result; }
-
測試結果
(2) 壓測產生堆外內存溢出問題
- springboot2.0以後默認使用lettuce作爲操作redis的客戶端,它使用netty進行網絡通信
- lettuce的bug導致netty堆外內存溢出 -Xmx300m: netty如果沒有指定 堆外內存,默認會使用這個 -Xmx300m。可以通過
-Dio.neety.maxDirectMemory
進行設置 - 解決方案:
- 1)、升級lettuce客戶端
- 2)、切換使用jedis
- 暫時解決方案:
<!-- redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <exclusions> <exclusion> <groupId>io.lettuce</groupId> <artifactId>lettuce-core</artifactId> </exclusion> </exclusions> </dependency> <!-- jedis客戶端 --> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> </dependency>
(3) RedisTemplate底層原理
Lettuce和Jedis是redis的客戶端,RedisTempalte是對Lettuce和Jedis的再一層封裝
-
- RedisAutoConfiguration自動配置類,會導入Lettuce和Jedis的配置類
- RedisAutoConfiguration自動配置類,會導入Lettuce和Jedis的配置類
-
- JedisConfiguration.java 類會給容器放一個@Bean: jedisConnectionFactory
4、高併發下一些緩存失效的問題
(1) 緩存穿透
- 概念: 當高併發查詢一個數據庫和緩存都不存在的數據,每次查詢都會去查數據庫,導致數據庫崩潰失效。如果我們第一次查的時候將查到的null加入緩存並設置過期時間,這時高併發請求就會在緩存中查到結果,不會查數據庫了。
- *風險: 利用不存在數據進行攻擊,數據庫瞬時壓力增大,最終導致數據庫崩潰。
- 解決方法:
- 數據庫查到的null值放入緩存,並加入短暫過期時間
- 布隆過濾器(請求先查布隆過濾器,再查緩存,數據庫)
(2) 緩存雪崩
- 概念: 我們設置的key值採用了相同的過期時間,當緩存某一刻同時失效後,此時出現高併發訪問時,會將請求全部轉發到數據庫,導致數據庫瞬時壓力過重。
- 解決方法:
-
- 將原有的失效時間基礎上增加一個隨機值,這樣相同過期時間的重複率就大大降低了,很難引起集體失效的事件
-
- 降級和熔斷
-
- 採用哨兵或集羣模式,從而構建高可用的Redis服務
-
- 如果已發生緩存雪崩: 熔斷,降級
(3) 緩存擊穿
- 概念: 一條高熱度的key過期了,面對高併發訪問,會將所有請求轉發到數據庫
- 解決方法:
- 加分佈式鎖,第一個請求獲得鎖,去查數據庫,其他人等待。
- 熱點數據不設置過期時間
5、本地鎖與分佈式鎖
(1) 本地鎖 synchronized
- 給整個緩存方法枷鎖,防止緩存擊穿。
- 問題: 本地鎖時序問題,意思就是當第一個線程釋放鎖後,還沒來得及將數據放入緩存,第二個線程就進去鎖了,(即沒鎖住)
- 解決:把存入緩存的操作放在鎖中
- 完整代碼:
public Map<String, List<Catalog2Vo>> getCatalogJsonFromDb() { //只要是同一把鎖,就能鎖住需要這個鎖的所有線程 //synchronized(this); springboot 所有組件在容器內都是單例的 // TODO 本地鎖: synchronized, JUC(Lock) //this代表當前實例對象 synchronized (this) { //得到鎖後,我們應該再去緩存中確定一次, 如果沒有才需要繼續查詢 String catalogJSON = redisTemplate.opsForValue().get("catalogJSON"); if(!StringUtils.isEmpty(catalogJSON)) { Map<String, List<Catalog2Vo>> result = JSON.parseObject(catalogJSON, new TypeReference<Map<String, List<Catalog2Vo>>>(){}); return result; } System.out.println("查詢了數據庫"); //獲得所有數據 List<CategoryEntity> selectList = baseMapper.selectList(null); //1、 獲得所有一級分類數據 List<CategoryEntity> level1Categorys = getParent_cid(selectList, 0L); //2、封裝數據 Map<String, List<Catalog2Vo>> collect = level1Categorys.stream().collect(Collectors.toMap(k -> k.getCatId().toString(), level1 -> { //查到當前1級分類的2級分類 List<CategoryEntity> category2level = getParent_cid(selectList, level1.getCatId()); List<Catalog2Vo> catalog2Vos = null; if (category2level != null) { catalog2Vos = category2level.stream().map(level12 -> { //查詢當前二級分類的三級分類 List<CategoryEntity> category3level = getParent_cid(selectList, level12.getCatId()); List<Catalog2Vo.Catalog3Vo> catalog3Vos = null; if (category3level != null) { catalog3Vos = category3level.stream().map(level13 -> { return new Catalog2Vo.Catalog3Vo(level12.getCatId().toString(), level13.getCatId().toString(), level13.getName()); }).collect(Collectors.toList()); } return new Catalog2Vo(level1.getCatId().toString(), catalog3Vos, level12.getCatId().toString(), level12.getName()); }).collect(Collectors.toList()); } return catalog2Vos; })); String s = JSON.toJSONString(collect); redisTemplate.opsForValue().set("catalogJSON", s, 1, TimeUnit.DAYS); return collect; } }
(2) 分佈式鎖
- 原理: 通過使用
set key value NX
命令,表示當沒有key時纔會set。通過這個命令來設置lock,設置成功相當於拿到了鎖
- 存在問題:
只要是流程圖上,任意兩個操作之間,都要考慮這裏突然宕機對方法的影響
- 問題1:設置鎖後,業務執行時宕機,導致沒有刪除鎖,造成了死鎖問題
- 解決: 通過
set key value EX 100 NX
中的EX設置上自動失效時間,即使宕機,鎖也會自動失效。 即保證原子加鎖
- 解決: 通過
- 問題2: 如果事務執行耗時,導致還沒結束,鎖自動失效了。那再次刪除鎖會影響其他線程
- 解決:通過value設置唯一標識uuid,刪鎖前比較解決。當然如果對比存在時間差,導致返回true後,鎖突然失效,這時刪除依然會刪除別的線程設置的鎖。
- 進一步解決:通過lua腳本解鎖
if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
- 問題1:設置鎖後,業務執行時宕機,導致沒有刪除鎖,造成了死鎖問題
- 完整分佈式鎖代碼:
//使用分佈式鎖 public Map<String, List<Catalog2Vo>> getCatalogJsonFromDbWithRedisLock() { //1、分佈式鎖。去redis佔坑,同時設置過期時間 //每個線程設置隨機的uuid, 也可以成爲token String uuid = UUID.randomUUID().toString(); //只有鍵key不存在的時候纔會設置key的值,保證分佈式情況下下一個鎖能進線程 //原子操作設置鎖,同時還指定了過期時間 Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS); if(lock) { //加鎖成功....執行業務 System.out.println("獲取分佈式鎖成功...."); Map<String, List<Catalog2Vo>> dataFromDb = null; //無論能否成功執行事務都要進行刪除鎖操作 try { dataFromDb = getDataFromDB(); } finally { //2、查詢uuid是否是自己,是自己的lock就刪除 //查詢+刪除,必須是原子操作,lua腳本解鎖 String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1]\n" + "then\n" + " return redis.call('del', KEYS[1])\n" + "else\n" + " return 0\n" + "end"; //刪除鎖, 把key和value傳給lua腳本 Long lock1 = redisTemplate.execute(new DefaultRedisScript<Long>(luaScript, Long.class), Arrays.asList("lock"), uuid); } return dataFromDb; } else { //加鎖失敗,重試 try { Thread.sleep(200); } catch (InterruptedException e) { e.printStackTrace(); } return getCatalogJsonFromDbWithRedisLock(); //自旋 } }