一種優雅實現多表查詢的新思路
哈嘍,各位代碼戰士們,我是Jensen,一個夢想著和大家一起在代碼的海洋里遨游,順便撿起那些散落的知識點的程序員小伙伴。
上一篇文章有一些小伙伴在吐槽我的AC架構:
這里我統一補充:AC架構不是銀彈,不適用于所有場景。對于需要精細化管理接口的業務,還是要拆開一個個接口去寫;而對于小而美的微服務、單表CRUD比較多的管理后臺,采用AC架構可以節省大量重復性編碼。
也有很多小伙伴私信我:使用AC架構怎么解決聯表查詢問題?本文將為大家揭曉。
本文涉及技術點:存儲方式、內存聚合。
一、冗余存還是范式存
冗余存儲的優缺點:
優點:
- 查詢性能:冗余存儲可以通過減少連接操作來提高查詢性能,因為所需數據已經被存儲在了同一個地方。
- 減少I/O:在查詢時,可以減少磁盤I/O操作,因為所有數據都在同一個表中。
- 簡化應用邏輯:應用層不需要處理復雜的多表關聯,簡化了應用邏輯。
缺點:
- 數據不一致:冗余數據可能導致數據不一致性問題,特別是在數據更新時,需要確保所有冗余的副本都被正確更新。
- 存儲空間:冗余存儲會占用更多的存儲空間,因為相同的數據在多個地方被存儲。
- 維護困難:隨著數據量的增加,冗余數據的維護變得更加困難,任何結構變更都可能涉及到多個表的修改。
范式存儲的優缺點:
優點:
- 數據一致性:范式化減少了數據冗余,有助于保持數據一致性。
- 存儲效率:通過消除重復數據,可以節省存儲空間。
- 數據完整性:范式化有助于實施數據完整性約束,如實體完整性、參照完整性等。
缺點:
- 查詢性能:范式化可能導致查詢性能下降,因為需要執行多表連接來獲取完整數據。
- 增加I/O:多表連接可能會增加磁盤I/O操作,特別是在涉及大型表的情況下。
- 復雜查詢:應用層可能需要編寫更復雜的SQL查詢來處理多表關聯。
我們再復習一下大學老師教過的數據庫范式(Normal Forms):
范式化是數據庫設計中的一個概念,旨在減少數據冗余和提高數據完整性。有幾種不同的范式,包括:
- 第一范式(1NF):每個表格的每個列都是不可分割的基本數據項。
- 第二范式(2NF):在1NF的基礎上,所有非主屬性完全依賴于主鍵。
- 第三范式(3NF):在2NF的基礎上,沒有傳遞依賴,即非主屬性只能依賴于主鍵,不能依賴于其他非主屬性。
在實際應用中,完全遵循范式化可能會導致查詢性能問題,因此通常會根據實際情況進行適度的反范式化(Denormalization),即有意引入一些冗余來優化性能。
設計數據庫時,需要在數據一致性、存儲效率和查詢性能之間做出權衡。
二、走進“聚合”
“聚合”二字經常出現在程序員之間、程序員與產品經理的對話中,雖然產品不懂技術語言,他們只管給客戶實現特定需求,為技術提供產品原型,但是技術比較關心到底是冗余存還是范式存。
如果涉及的數據需要分表存儲,單表查滿足不了客戶需求,這時就需要進行數據聚合,傳統的方式是寫SQL,Join連接多張表,返回多張表的數據給前端,這種方式對數據庫查詢有一定的壓力,整體性能雖然較好,但缺點也很明顯:
隨著業務量越來越大,工程里的SQL會滿天飛,并且SQL會寫得越來越復雜,對維護SQL的人簡直是噩耗!
這意味著什么?代碼寫了兩三年后,SQL已經很難維護了,沒人敢動,每次看SQL都特別費勁,還不知道哪一天會出現慢SQL。
其實除了寫SQL語句,我們還能通過倉庫實現層做數據聚合,這就是所謂的“內存Join”,把數據庫壓力轉移到了不要錢的CPU與內存中,具體方法如下:
- 查詢參數Query對象添加Boolean fillXxx字段,用于控制是否要聚合。
- 倉庫實現XxxRepositoryImpl實現fill(Query query, List<Model> models)方法,models就是查詢出主表的結果集,通過query.fillXxx=true控制聚合其他表的數據。
這個fillXxx就是聚合參數,大多數情況下是默認關閉的,也就是默認還是單表查詢,如果需要聚合,由前端傳參控制,這樣既可以兼顧性能,也可以兼顧功能,我們按需聚合數據即可。
下面我們看看一個真實案例,預約單倉庫實現,聚合預約主體、預約記錄、預約主體評價:
/**
* 預約單倉庫實現
*/
@Repository
public class AppointmentOrderRepositoryImpl extends
BaseRepositoryImpl<AppointmentOrderMapper, AppointmentOrder, AppointmentOrderPO, AppointmentOrderQuery> implements
AppointmentOrderRepository {
// 聚合方法
@Override
public void fill(AppointmentOrderQuery query, List<AppointmentOrder> appointmentOrders) {
Map<String, AppointmentOrder> orderId2appointment = appointmentOrders.stream().collect(Collectors.toMap(AppointmentOrder::getOrderId, o -> o));
if (query.getFillAppointment()) {
// 聚合預約主體
this.fillAppointment(appointmentOrders);
}
if (query.getFillAppointmentRecords()) {
// 聚合預約記錄列表
this.fillAppointmentRecords(orderId2appointment);
}
if (query.getFillAppointmentComment()) {
// 聚合預約主體評論
this.fillAppointmentComments(appointmentOrders);
}
}
private void fillAppointment(List<AppointmentOrder> appointmentOrders) {
// 通過預約單的主體ID列表(去重)查詢主體
Set<String> appointIds = appointmentOrders.stream().map(AppointmentOrder::getAppointId).collect(Collectors.toSet());
List<Appointment> appointments = AppointmentQuery.builder().idIn(appointIds).build().list();
if (CollKit.isEmpty(appointments)) {
return;
}
// 按主體ID映射,把主體的信息寫到預約單內
Map<String, Appointment> map = appointments.stream().collect(Collectors.toMap(Appointment::getId, o -> o));
for (AppointmentOrder appointmentOrder : appointmentOrders) {
Appointment appointment = map.get(appointmentOrder.getAppointId());
if (appointment != null) {
appointmentOrder.setAppointment(appointment);
appointmentOrder.setAppointName(appointment.getName());
}
}
}
private void fillAppointmentRecords(Map<String, AppointmentOrder> orderId2appointment) {
// 查出指定預約單ID列表的預約記錄
List<AppointmentRecord> appointmentRecords = AppointmentRecordQuery.builder().orderIdIn(orderId2appointment.keySet()).fillTimeSchedules(true).build().list();
if (CollKit.isEmpty(appointmentRecords)) {
return;
}
// 按預約單ID分組,讓預約單關聯多條預約記錄
Map<String, List<AppointmentRecord>> map = appointmentRecords.stream().collect(Collectors.groupingBy(AppointmentRecord::getOrderId));
for (String id : map.keySet()) {
orderId2appointment.get(id).setAppointmentRecords(map.get(id));
}
}
private void fillAppointmentComments(List<AppointmentOrder> appointmentOrders) {
// 查出指定預約單ID列表的主體評論
List<String> orderIds = appointmentOrders.stream().map(AppointmentOrder::getOrderId).collect(Collectors.toList());
List<AppointmentComment> appointmentComments = AppointmentCommentQuery.builder().orderIdIn(orderIds).build().list();
if (CollKit.isEmpty(appointmentComments)) {
return;
}
// 按預約單ID分組,讓預約單關聯首條預約主體評論
Map<String, List<AppointmentComment>> map = appointmentComments.stream().collect(Collectors.groupingBy(AppointmentComment::getOrderId));
for (AppointmentOrder appointmentOrder : appointmentOrders) {
appointmentOrder.setAppointmentComment(CollKit.isNotEmpty(map.get(appointmentOrder.getOrderId())) ? map.get(appointmentOrder.getOrderId()).get(0) : null);
}
}
}
需要注意的是,fill方法要對多條結果進行批量處理,如果是單個結果,每一條結果都需要聚合多表數據,那勢必會加大內存與數據庫連接的負擔。以上預約單表聚合其他三張表,比SQL的Join多調了3次查詢,但好在邏輯清晰,減少維護SQL的負擔。
聚合查詢的打開方式就很簡單了,如果前端不傳聚合參數,那么由后端接口來控制:
// 設置聚合參數(或由前端傳入)
query.setFillAppointment(true);
query.setFillAppointmentRecords(true);
query.setFillAppointmentComment(true);
// 分頁查詢后,會自動調用fill方法,對結果集聚合
Page<AppointmentOrder> page = query.page();
// 調用聚合方法(以下代碼在D3Boot框架內已實現,不需要寫)
appointmentOrderRepository.fill(query, page.getRecords());
我們通過這種方式來靈活聚合多表數據,不僅不用改動Controller,還能兼顧多種數據聚合的場景,這種數據的聚合,不一定要聚合數據庫的數據,也能聚合第三方接口的數據。倉庫接口只關心需要提供什么數據即可,怎么聚合數據、數據源來自哪兒,是倉庫實現要負責的事情。
三、寫在最后
目前這種聚合方式也只適用于大部分場景,對于多張大表的聚合,還得考慮是用數據庫Join還是內存Join的方式進行,或者在設計的時候就要考慮冗余存而不是范式存。