Java專案問題

莫非的Java發表於2020-10-05

專案概述:

利用SpringBoot+MySQL/ Redis+ thymeleaf/Vue.js+ Restful架構完成購物商城的基本功能:
商品的分類查詢和屬性設定+訂單狀態流轉;
CRUD後臺各種功能(Mybatis/JPA規範)、事務的使用;
使用Redis對資料進行快取處理、快取和資料庫的一致性問題、快取雪崩和快取穿透問題解決⽅案;
Restful架構實現前後端分離。

開發流程:

1. 表結構的設計

使用者相關:使用者表、評價表;
產品相關:分類表、屬性表、產品表;
訂單相關:訂單表、訂單單項表。

因為表與表之間存在一對多關係,一開始採取的方案是直接在SQL層對應表中建立外來鍵和相應的外來鍵約束,比如訂單單項表裡有外來鍵分別對應使用者表、商品表、訂單表裡的主鍵,但是後來發現外來鍵和外來鍵約束在專案開發過程中會帶來一些問題,因此後面採取的方案是取消外來鍵約束,將原有的外來鍵只作為對應表中的普通欄位來使用,相應的一對多關係在pojo層對應的實體類中使用註釋 @ManyToOne @JoinColumn(name="") 來實現。
常見的關係類註釋:@OneToOne(一對一)、@OneToMany(一對多)、@ManyToOne(多對一)、@ManyToMany(多對多)

外來鍵和外來鍵約束的優點和缺陷:
優點:
外來鍵,表的外來鍵是另一表的主鍵,外來鍵是可以有重複的,可以是空值。通過引入外來鍵和外來鍵約束可以保證資料的完整性和一致性,使得級聯操作更加方便,此外將資料完整性判斷託付給了資料庫完成,也減少了程式的程式碼量。
缺陷
但是,阿里Java規範中強制要求:sql不得使用外來鍵與級聯,一切外來鍵概念必須在程式內解決。 這是因為:

  1. 每次對資料進行DELETE或UPDATE操作都必須考慮外來鍵約束,資料庫都會判斷當前操作是否違反資料完整性,從而導致資料庫效能下降。而如果交由程式控制,這種查詢過程就可以控制在我們手裡,可以省略一些不必要的查詢過程。
  2. 併發問題:在使用外來鍵的情況下,每次修改資料都需要去另外一個表檢查資料,需要獲取額外的鎖。若是在高併發大流量事務場景下,使用外來鍵更容易造成死鎖。
  3. 擴充套件性問題:在水平拆分和分庫的情況下,外來鍵是無法生效的。將資料間關係的維護,放入應用程式中,可以為將來的分庫分表省去很多的麻煩。

2. application.properties、pom.xml

核心配置檔案 application.properties:
用於儲存相關配置資訊,如資料庫相關配置資訊(使用者名稱、密碼、介面等)、Redis相關配置資訊等等。
(springboot裡除了使用.properties外,還支援 yml格式,只是書寫語法略有不同)

配置檔案 pom.xml:
在其中新增需要的依賴(dependency):
springboot web、springboot tomcat、redis、 mysql、jpa等。

3. pojo實體層
對應每個資料表建立對應的實體類,主要使用的註釋如下:
類註釋:
@Entity 表示這是一個實體類;
@Table(name = “category”) 用於標識該實體類對應的資料表的名稱;
屬性註釋:
@Id: 用於宣告一個實體類的屬性對映為資料庫的主鍵列。

@GeneratedValue: 用於標註主鍵的生成策略,預設情況下,JPA 自動選擇一個最適合底層資料庫的主鍵生成策略。

@Column:用來標識實體類中屬性與資料表中欄位的對應關係。

@ManyToOne:標識實體之間多對一關聯關係。

@JoinColumn :指定與實體類相關聯的資料庫表中的列欄位。由於@ManyToOne等註解只能確定實體之間幾對幾的關聯關係,並不能指定與實體相對應的資料庫表中的關聯欄位,因此,需要與 @JoinColumn 註解來配合使用。

@Transient:實體類需要新增一個屬性,但是這個屬性又不希望儲存至對應的資料庫,僅僅是做個臨時變數用一下。

此外,如果既沒有指明關聯到哪個Column,又沒有明確要用@Transient忽略,那麼就會自動關聯到資料表對應的同名欄位。

示例如下:

@Entity
@Table(name = "product")
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id")
    int id;
     
    @ManyToOne
    @JoinColumn(name="cid")
    private Category category;
     
    //如果既沒有指明 關聯到哪個Column,又沒有明確要用@Transient忽略,那麼就會自動關聯到表對應的同名欄位
    private String name;
     
    @Transient
    private ProductImage firstProductImage;
    }

4. Dao((Date Access Object) )資料訪問層
本專案中的DAO 介面繼承了 JpaRepository,就提供了CRUD和分頁的各種常見功能。JPA 是不需要寫 SQL 語句的,只需要在 dao 介面裡按照規範的命名定義對應的方法名,即可達到查詢相應欄位的效果了。
該介面中沒有使用到SpringBoot中的任何註釋。
在這裡插入圖片描述
5. Service層
在該層中定義控制層需要呼叫的一些具體的方法。

涉及到的相應註釋如下:

@Service:標記這個類是 Service類
@Autowired :可以對類成員變數、方法及建構函式進行標註,完成自動裝配的工作,通過 @Autowired的使用來消除 set ,get方法。
(@Resource的作用相當於@Autowired,只不過@Autowired按byType自動注入,而@Resource預設按 byName注入。

@Autowired:在啟動spring IoC時,容器自動裝載了一個AutowiredAnnotationBeanPostProcessor後置處理器,當容器掃描到@Autowied時,就會在IoC容器自動查詢需要的bean,並裝配給該物件的屬性。
@Resource:原理和上面的相同,只是使用的後置處理器為CommonAnnotationBeanPostProcessor。)

@Service
public class CategoryService {
    @Autowired CategoryDAO categoryDAO;
    public List<Category> list() {
        Sort sort = new Sort(Sort.Direction.DESC, "id");
        return categoryDAO.findAll(sort);
    }
}

6. Controller層
本專案是按照前後端分離概念實現的,其中資料部分是通過Restful標準下實體類的相應的Controller類來實現的,而在業務上,除了資料服務要提供,還要提供頁面跳轉服務,將所有的後臺頁面跳轉實現都放在PageController類中,這樣程式碼更清晰。

對於PageController類:
@Controller :表示這是一個控制器。
@GetMapping(value=""):用於處理請求方法的GET型別

@Controller
public class AdminPageController {
    @GetMapping(value="/admin")
    public String admin(){
        return "redirect:admin_category_list";//重定向路徑
    }
    @GetMapping(value="/admin_category_list")
    public String listCategory(){
        return "admin/listCategory";
    }
}

資料相關的Controller類(Restful標準):
@RestController:表示這是一個控制類,並且對類中的每個方法的返回值都會直接轉換為 json 資料格式。
@Autowired :自動裝配。

Restful標準相關:
url中資源名稱用複數,而非單數。即:使用 /categories 而不是用 /category
CRUD的URL就都使用一樣的 “/categories”,區別只是在於method不同,即Springboot相應的註解不同,伺服器根據method的不同來判斷瀏覽器期望做的業務行為。
增加: @PostMapping
刪除: @DeleteMapping
修改: @PutMapping
查詢: @GetMapping

@RestController
public class CategoryController {
    @Autowired CategoryService categoryService;
     
    @GetMapping("/categories")
    public List<Category> list() throws Exception {
        return categoryService.list();
    }
}

@RestController 和 @Controller的區別:
Controller:返回⼀個⻚⾯,單獨使⽤ @Controller不加@ResponseBody 的話⼀般使⽤在要返回⼀個檢視的情況,這種情況屬於⽐較傳統的Spring MVC 的應⽤,對應於前後端不分離的情況。@ResponseBody 註解的作⽤是將 Controller 的⽅法返回的物件通過適當的轉換器轉換為指定的格式之後,寫⼊到HTTP 響應(Response)物件的 body 中,通常⽤來返回 JSON 資料。

@RestController:返回JSON形式資料,@RestController 只返回物件,物件資料直接以 JSON 形式寫⼊ HTTP 響應(Response)中,這種情況屬於 RESTful Web服務,這也是⽬前⽇常開發所接觸的最常⽤的情況(前後端分離)。

即:@Controller+@ResponseBody =@RestController。

7. Application.java
建立 Application.java,其註解 @SpringBootApplication 表示這是一個SpringBoot應用,執行其主方法就會啟動tomcat,預設埠是8080。

8. Redis的部署
這裡只介紹前期的準備工作,後面具體的資料處理放在下面專案問題中的第二條中進行講述。

  1. 在配置檔案application.properties裡增加 redis的相關配置(伺服器地址,埠,密碼等),並在pom.xml中新增Redis需要的依賴(dependency)。
  2. 修改 Application, 增加註解: @EnableCaching 用於啟動快取,同時,檢查埠6379是否啟動。 6379 就是 Redis 伺服器使用的埠。如果未啟動,設定退出 springboot。
@SpringBootApplication
@EnableCaching
public class Application {
    static {
        PortUtil.checkPort(6379,"Redis 服務端",true);//PortUtil是引入的工具類
    }
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

9. 事務的使用
使用者的下單減庫存操作是一個事務操作,需要先查詢出當前的商品庫存,然後插入一條資料至訂單表中,最後將商品的庫存執行update -1操作。

上述操作可以非常方便的使用SpringBoot中的事務操作來實現。

SpringBoot 使用事務非常簡單,首先在啟動主類使用註解 @EnableTransactionManagement 開啟事務支援後,然後在對應的Service方法上新增註解 @Transactional 即可。

一般常用的是@Transactional(rollbackFor = Exception.class)
如果不配置rollbackFor屬性,那麼事務只會在遇到執行時異常的時候才會回滾,反之加上rollbackFor=Exception.class,可以讓事務在遇到非執行時異常時也回滾,即如果方法丟擲異常,資料庫裡面的資料就會回滾。

當@Transactional註解作用於類上時,該類的所有 public 方法將都具有該型別的事務屬性,同時,我們也可以在方法級別使用該標註來覆蓋類級別的定義。

此外,如果異常被try{}catch{}了,事務就不回滾了,因為一旦你try{}catch{}了。系統會認為你已經手動處理了異常,就不會進行回滾操作。因此,如果我們在使用事務 @Transactional 的時候,想自己對異常進行處理的話,那麼我們可以進行手動回滾事物。在catch中加上 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); 方法進行手動回滾。

10. 前後端分離
html 頁面的內容可以簡單看成 包含資料部分和不包含資料部分。 所以先準備一個不包含資料的html, 把它傳給瀏覽器,這個速度本身會非常快,因為沒有最佔時間的資料庫操作部分。 然後再通過 Ajax 技術,僅僅從伺服器獲取“純資料”,然後把純資料顯示在html上。

這樣做的好處:
即便是後臺資料庫比較花時間,但是使用者體驗也比前面的方式好,因為使用者會先看到部分頁面,過一小會兒再看到資料,比在空白頁面打圈圈等待體驗好。
後端只提供資料,所以前後端開發耦合度降低了很多,整體開發效率可以得到較大提高。

2.專案中遇到了哪些問題?怎麼解決的?

1. 外來鍵和外來鍵約束問題
表與表之間存在一對多關係,比如使用者和訂單單項表,一開始採取的方案是直接在SQL層對應表中建立外來鍵和相應的外來鍵約束,比如訂單單項表裡有外來鍵分別對應使用者表、商品表、訂單表裡的主鍵,但是後來發現外來鍵和外來鍵約束在專案開發過程中會帶來一些問題,且對於後續的併發處理和擴充套件而言並不方便,因此後面採取的方案是取消外來鍵約束,將原有的外來鍵只作為對應表中的普通欄位來使用,相應的一對多關係在pojo層對應的實體類中使用註釋 @ManyToOne @JoinColumn(name="") 來實現。
常見的關係類註釋:@OneToOne(一對一)、@OneToMany(一對多)、@ManyToOne(多對一)、@ManyToMany(多對多)

外來鍵和外來鍵約束的優點和缺陷:
優點:
外來鍵,表的外來鍵是另一表的主鍵,外來鍵是可以有重複的,可以是空值。通過引入外來鍵和外來鍵約束可以保證資料的完整性和一致性,使得級聯操作更加方便,此外將資料完整性判斷託付給了資料庫完成,也減少了程式的程式碼量。
缺陷
但是,阿里Java規範中強制要求:sql不得使用外來鍵與級聯,一切外來鍵概念必須在程式內解決。 這是因為:

  1. 每次對資料進行DELETE或UPDATE操作都必須考慮外來鍵約束,資料庫都會判斷當前操作是否違反資料完整性,從而導致資料庫效能下降。而如果交由程式控制,這種查詢過程就可以控制在我們手裡,可以省略一些不必要的查詢過程。
  2. 併發問題:在使用外來鍵的情況下,每次修改資料都需要去另外一個表檢查資料,需要獲取額外的鎖。若是在高併發大流量事務場景下,使用外來鍵更容易造成死鎖。
  3. 擴充套件性問題:在水平拆分和分庫的情況下,外來鍵是無法生效的。將資料間關係的維護,放入應用程式中,可以為將來的分庫分表省去很多的麻煩。

2. Redis在SpringBoot中的使用,其和資料庫的資料一致性

如何保證快取與資料庫的資料⼀致?

我一開始嘗試採取的是一般的更新策略:即資料庫更新時快取對應項也進行更新的方式,使用註釋@CachePut,後來發現這樣做可能會帶來一些問題,即資料庫中的資料發生改變的時候,快取中的資料應該採取刪除操作,而不是更新操作。
問:為什麼是刪除快取,而不是更新快取?
(1)當寫場景比較多,而讀場景比較少時,採用快取更新方案就會導致,資料壓根還沒讀到,快取就被頻繁的更新,浪費效能。
(2)另外,如果寫入資料庫的值,並不是直接寫入快取的,而是要經過一系列複雜的計算再寫入快取。那麼,每次寫入資料庫後,都再次計算寫入快取的值,無疑是浪費效能的。顯然,比起更新快取,直接刪除快取更為適合。

確定好刪除快取而不是更新快取之後,另一個需要確定的問題就是更新資料庫和刪除快取的先後問題。通過查詢一些資料發現,如果先刪除快取,再更新資料庫,會經常發生併發問題。

問:如果先刪除快取,再更新資料庫,會有什麼問題?
如果先刪快取,再更新資料庫,首先會出現下面的問題:
有一個請求A進行更新操作,同時,另一個請求B進行查詢操作。那麼會出現如下情形:
(1)請求A進行寫操作,刪除快取
(2)請求B查詢發現快取不存在
(3)請求B去資料庫查詢得到舊值
(4)請求B將舊值寫入快取
(5)請求A將新值寫入資料庫
這時就會導致資料庫和快取資料不一致的情況。而且,如果不給快取設定過期時間,該資料永遠都是髒資料。而如果採用先更新資料庫再刪除快取,由於資料庫的讀操作的速度遠快於寫操作的,因此不像先刪除快取再更新資料庫那樣容易出現併發問題。

但是還是會有其他問題的,如果快取刪除失敗了怎麼辦?
如果刪除快取失敗了,那麼會導致資料庫中是新資料,快取中是舊資料,資料就出現了不一致。
如何解決?提供一個保障的重試機制即可,繼續重試刪除操作,直到刪除成功。

以上即為較為經典的快取+資料庫讀寫的模式,就是 Cache Aside Pattern。
讀的時候,先讀快取,快取沒有的話,就讀資料庫,然後取出資料後放入快取,同時返回響應。
更新的時候,先更新資料庫,成功後,再讓刪除相應快取。

採用先更新資料庫,再刪除快取思路時,Redis在SpringBoot中的使用:

  1. 在配置檔案application.properties裡增加 redis的相關配置(伺服器地址,埠,密碼等),並在pom.xml中新增Redis需要的依賴(dependency)。
  2. 修改 Application, 增加註解: @EnableCaching 用於啟動快取,同時,檢查埠6379是否啟動。 6379 就是 Redis 伺服器使用的埠。如果未啟動,設定退出 springboot。
  3. Redis快取,一般在 Service 這一層上進行實現,以CategoryService 為例。
    首先給CategoryService類上加上註解@CacheConfig,寫明cahceNames屬性,表示當前類檔案中快取裡的keys,都儲存在 “categories” 中。
@CacheConfig(cacheNames="categories")

對於查詢get方法,使用@Cacheable註解,
第一次訪問的時候, redis 是不會有資料的,所以就會通過 jpa 到資料庫裡去取出來,一旦取出來之後,就會放在 redis裡。第二次訪問的時候,redis 就有資料了,就不會從資料庫裡獲取了。

@Cacheable(key="'categories-one-'+ #p0")
public Category get(int id) {
	Category c= categoryDAO.findOne(id);
	return c;
}

增加,刪除和修改這些涉及到資料庫更新的操作一律使用註解@CacheEvict,其引數beforeInvocation設定為預設值false,即在方法執行之後才會刪除指定的快取,且該註釋可以保證如果標註的方法因為丟擲異常而未能成功返回時不會觸發快取刪除操作。

@CacheEvict(allEntries=true)
public void add(Category bean) {
	categoryDAO.save(bean);
}
@CacheEvict(allEntries=true)
public void delete(int id) {
	categoryDAO.delete(id);
}
@CacheEvict(allEntries=true)
public void update(Category bean) {
	categoryDAO.save(bean);
}

補充:@CacheEvict的用法
@CacheEvict是用來標註在需要清除快取元素的方法或類上的。當標記在一個類上時表示其中所有的方法的執行都會觸發快取的清除操作,使用該註解標誌的方法,會清空指定的快取。如果方法因為丟擲異常而未能成功返回時不會觸發快取清除操作。
常用屬性:
在這裡插入圖片描述
3. Redis快取穿透問題解決⽅案

為了應對Redis快取穿透問題,提前做了相關預案。

快取穿透
快取穿透說簡單點就是⼤量請求的 key 根本不存在於快取中,導致請求直接到了資料庫上,根本沒有經過快取這⼀層。
比如,資料庫 id 是從 1 開始的,如果請求 id 全部都是負數,此時,快取中不會有,請求每次都會越過快取,直接查詢資料庫。這種惡意攻擊場景的快取穿透就會直接使資料庫崩潰。

解決方案:

最基本的就是⾸先做好引數校驗,⼀些不合法的引數請求直接丟擲異常資訊返回給客戶端。⽐如查詢的資料庫 id 不能⼩於 0等等。

另外可以採取快取⽆效 key的方案,即如果快取和資料庫都查不到某個 key 的資料就寫⼀個對應的空值到 redis 中去並設定過期時間。這種⽅式可以解決請求的 key 變化不頻繁的情況,如果⿊客惡意攻擊,每次構建不同的請求key,會導致 redis 中快取⼤量⽆效的 key 。很明顯,這種⽅案並不能從根本上解決此問題。

我採取的解決方案是利用布隆過濾器。即在快取層之上加一個布隆過濾器,把所有可能存在的請求的值都存放在布隆過濾器中,當⽤戶請求過來,會先判斷⽤戶發來的請求的值是否存在於布隆過濾器中。不存在的話,直接返回請求引數錯誤資訊給客戶端,存在的話才會⾛下⾯的快取和資料庫流程。

嘗試了兩種方式來實現:

a. 利用Google開源的 Guava中自帶的布隆過濾器
較為方便,使用時只需要在專案中引入 Guava的依賴。通過使用BloomFilter.create()命令即可建立一個布隆過濾器,並可以指定其容量和誤判概率等引數。

// 建立布隆過濾器物件
BloomFilter<Integer> filter = BloomFilter.create(
        Funnels.integerFunnel(),
        1500,
        0.01);
// 將元素新增進布隆過濾器
filter.put(1);
filter.put(2);
// 判斷指定元素是否存在
System.out.println(filter.mightContain(1));
System.out.println(filter.mightContain(2));

Guava 提供的布隆過濾器的實現很方便,其缺點就是隻能單機使用,不適用於分散式場景,我也嘗試使用了 Redis 中的Bloom Filter布隆過濾器。

b. 給Redis安裝Bloom Filter
Redis 4.0 之後有了 Module 功能,可以讓 Redis 使用外部模組擴充套件其功能 。布隆過濾器就是其中的 Module。我使用的是官網推薦的RedisBloom 作為 Redis 布隆過濾器的 Module。下載並編譯模組之後,修改其redis.conf檔案,增加redisbloom相關的配置,即可實現RedisBloom每次啟動自載入:
Redis布隆過濾器與springboot的整合
仿照github上的教程,編寫兩個lua指令碼:
a指令碼實現新增資料到指定名稱的布隆過濾器,b指令碼實現從指定名稱的布隆過濾器獲取key是否存在。
從而實現springboot和布隆過濾器的整合。

壓測:使用jmeter進行測試。

4. 秒殺問題

高併發導致超賣問題
建立了item_kill待秒殺商品表和item_kill_success秒殺成功訂單表。

使用雪花演算法生成訂單編號,它是一個開源的分散式id生成演算法,可以快速生成遞增id就可以了。

將活動頁面上的所有可以靜態的元素全部靜態化,並儘量減少動態元素。

利用Redisson的分散式鎖來實現下單過程,從而解決高併發場景下出現的超賣問題。
(與redis的區別:Redisson可以設定鎖的等待時間和過期時間)

@Autowired
private RedissonClient redissonClient;
//redisson的分散式鎖
@Override
public Boolean KillItemV4(Integer killId, Integer userId) throws Exception {
    Boolean result=false;
    final String key=new StringBuffer().append(killId).append(userId).toString();
    RLock lock=redissonClient.getLock(key);
    //三個引數、等待時間、鎖過期時間、時間單位
    Boolean cacheres=lock.tryLock(30,10,TimeUnit.SECONDS);
    if (cacheres){
        //判斷當前使用者是否搶購過該商品
        if (itemKillSuccessMapper.countByKillUserId(killId,userId)<=0){
            //獲取商品詳情
            ItemKill itemKill=itemKillMapper.selectByidV2(killId);
            if (itemKill!=null&&itemKill.getCanKill()==1 && itemKill.getTotal()>0){
                int res=itemKillMapper.updateKillItemV2(killId);
                if (res>0){
                    commonRecordKillSuccessInfo(itemKill,userId);
                    result=true;
                }
            }
        }else {
            System.out.println("您已經搶購過該商品");
        }
        lock.unlock();
    }
    return result;
}

測試:
清空item_kill_success表,將item_kill表中要賣的商品總數設定為一個較小的數,比如10,利用jmeter開啟1000個執行緒開始秒殺測試。

相關文章