空間換時間-將查詢數據性能提升100倍的計數系統實踐
1 背景
俠客匯的業務運營,根據目前公司的業務體量和運營方式,結合市場上對標競品的DAU數據分析,再借鑒國際上有很多會員制的自由交易市場玩法,決定建立一個B2B的二手同行自由交易平臺。通過提供擔保交易能力,讓所有交易能在平臺內完成閉環,平臺通過真實數據為商戶提供信息認證,打造具有公信力的背書。通過推薦和風控能力形成護城河,讓用戶留在平臺,實現合作共贏。
2 前言
在信息爆炸的時代,計數系統幾乎無處不在,從社交平臺上的點贊量、評論數,到電商平臺的瀏覽量、訂單數量,再到內容網站的訪問量統計,計數系統為各種業務提供了實時、準確的數據支持。這些數據不僅是簡單的數字,它們可以反映出用戶對內容的興趣、商品的受歡迎程度、市場的需求變化,甚至可以用于預測未來趨勢。對于企業和平臺而言,計數系統能夠提供一個有效的量化依據,幫助優化產品、制定營銷策略、提升用戶體驗,因此它成為了數據統計和用戶分析的核心工具。
3 需要統計的計數維度
3.1 按照統計內容維度劃分
- 內容維度:用戶發帖數,帖子評論數,帖子點贊數,評論列表未讀總數,關注列表未讀總數,點贊列表未讀總數,評論點贊數,靠譜未讀計數,同行認證未讀計數,商品詳情瀏覽數量,商品負反饋數量。
- 用戶維度:近期動態個數,用戶關注數,用戶粉絲數。
- 交易維度:用戶回收單成交數,用戶送檢數,報價單計數,用戶B2B訂單賣出計數, 用戶B2B訂單買入計數,用戶發布商品計數,用戶已售商品計數,用戶已購商品計數。
3.2 按照統計時間維度劃分
- 實時維度:上述內容維度的統計基本都是實時統計的維度。
- 時間段維度:最近N天發布的商品數,最近N天售賣商品的數量,最近N天發布的帖子數等等。
具體案例:
1.個人主頁會有我關注的數量,關注我的數量,靠譜數量,交易數量,以及一系列的按時間段統計的跑馬燈數據。多數用戶維度相關的數據都是在個人中心的上半部分進行展示,而下半部分,則是對這個用戶內容維度,交易維度統計的一個匯總。同時,統計的數據,既有實時維度,也會有時間段維度,例如,我們會統計用戶的總的交易單量,也會統計這個用戶最近N天的交易數量。
2.在首頁找同行頁面,顧名思義,找同行頁面就是用來尋找用戶的,所以這個頁面會重點展示這個用戶在用戶維度的數據統計,同行關注數,交易人數,靠譜數等。
3.在自由市場頁面,我們同樣會重點展示這個用戶在用戶維度的數據統計,但是與此同時,我們還會展示他的交易評分,方便有需要的用戶,能夠多維度的篩選自己的交易伙伴。
4.在消息通知頁面,我們則著重統計這個用戶需要關注的動態數據,點贊數,評論數,新增關注數量等等。
4 為什么要做自己的計數系統
上面已經陳述了我們所需要的統計的計數業務場景,以及不同的統計維度和統計方式。簡單的將我們需要的計數功能做一個總結:
- 基礎功能:能夠實時計算需要統計數量的總數,而且能夠批量查詢。
- 拓展功能:能夠兼容不同的業務場景,而且能夠由業務方自己控制增減的數量。
- 進階功能:能夠根據時間段進行查詢,而且時間段由業務方自由控制,支持不同的時間維度,分鐘,小時,天,周,月。
功能總結完成,從節省時間和資源的角度來說,公司的其他部門是不是已經有對應的功能可以提供了呢?我們首先想到的是數據組,畢竟數據組在整理和統計數據這方面是專業的。其次我們想到的是中臺系統,是不是有對應的功能可以直接提供。結果調研之后發現,兩個部門現有的功能,并不能完全支持我們的需求。
- 數據組方面,能夠支持我們不同的業務場景的統計,也可以支持我們的時間段查詢。但是,數據組目前的數據提供方式是T+1的模式,并不能夠對我們的業務場景進行實時的統計。也就是說,他滿足了我們的拓展功能和進階功能,但是在基礎功能方面的支持是有限的。
- 中臺方面,計數系統的功能是有的,但是功能相對簡單,由中臺來分配計數場景,然后由業務同學來驅動這個計數的加減?;A功能,拓展功能全都支持,如果計數過期,還可以通過回源數據接口來取數據。但是對于根據時間段查詢,也就是我們需要的進階功能,并不支持。
基于以上調研結果,并沒有現有的功能能夠直接滿足我們所有的需求,我們只能自己來實現這個計數的功能。
5 計數系統的設計方案
既然已經決定要自己來做,那么就要從業務實際需要的業務場景來設計我們需要實現的功能。計數系統總的來說,實現我們所需要的功能并不是什么難點,設計方案的選擇,主要還是看我們自己的側重點,如果是開發時間角度考慮,可以選能夠快速實現功能的短期方案。如果是從長遠角度考慮,想讓計數系統獨立承擔一個類似中臺的角色,那也可以將計數系統作為一個獨立的通用模塊進行開發。
5.1 計數內置的架構設計
在前言中已經說過,本次開發是臨危受命,時間很緊張,那么如果想要在有效的時間內完成本次開發,時間是不可忽略的客觀條件。那么,最快速的方案莫過于直接采用count數據表+緩存的方式,這種方式在體量較小時,成本低、性能高、絕對精準,但隨著統計數據的體量逐漸變大、微服務拆分越來越細之后,該方案就會越來越難以支撐業務。
count表方案,基本可以總結為:
- 一次請求多次查詢,for循環進行
- 一次請求多次查詢,多個計數的查詢
- 一次查詢一個count,每個計數都是一個count語句
所以這種方案一定會造成以下這幾種問題:
- 性能瓶頸:隨著數據體量的越來越大,再用count的方式去進行數據統計,性能會變得越來越差。
- 穩定性風險:如果業務統計規則變得越來越復雜,使用數據庫count的方式會使數據查詢語句越來越復雜,容易引發慢SQL從而導致數據庫不穩定。而且將計數的業務層和緩存與核心的業務邏輯放在一起,如果統計數據出現了問題,會影響核心業務的使用,小的問題會變成大的問題。
- 一致性問題:部分計數場景下是定時更新緩存的策略,緩存操作和MySQL操作無法在一個事務中完成,會產生不一致的問題,且在越頻繁變更的場景下差異值就會越大。
綜上所屬,短期的方案雖然短小精悍,但是后期的隱患比較大,維護成本會很高,不是合理的選擇方案。
5.2 計數外置的架構設計
結合俠客匯當前的業務現狀、體量以及考慮中長期體量增長的規劃,我們也調研了業內比較常見的一些實現方案,最終決定單獨維護一套計數系統。由計數系統來統計俠客匯所有的計數邏輯,計數系統和具體的業務邏輯完全脫離,只負責統計各個業務場景的計數,具體的流程圖如下:
5.2.1 計數方案中字段的定義
- 全局計數器:記錄一個業務key的總量值,比如內容點贊總數,由一個全局計數器記錄即可。邏輯結構為(業務類型+實體id):value值。
- 計數流水:實時上報的計數流水,可以支持按照精確時間維度查詢功能。
- 業務類型:業務類型確定計數的接入業務,如交易單總量計數、內容點贊計數,為不同的接入業務類型。
- 計數實體:實體id確定計數的目標對象,比如交易單、社區發布內容等為一個計數實體。實體id在同一業務類型內保證唯一性,如交易單總量計數業務,交易單id不能重復。
之所以設計了這幾個字段,是因為我們可以通過最小的代價來實現最通用的功能。
- 全局計數器必不可少,這是整個計數系統的靈魂,也是我們最常用的數據統計功能。
- 計數流水是為了保存原始的數據,后期如果有任何數據遷移,或者數據丟失后需要回源數據的,計數流水都會最后的屏障。
- 業務類型是為了兼容各個業務場景,如果沒有這個字段,那么每個業務場景我們都需要一個接口來進行統計,每個業務場景我們都需要一個表來保存數據,對后續拓展極為不利,也不能體現計數系統的通用性。
- 計數實體的作用有兩個,一方面如果沒有實體id,只有業務類型的話,后續數據如果出現問題需要進行查詢的時候,我們無從查起。另一方面,有了計數實體,我們才能做一些冪等相關的校驗操作。
以上字段,同樣是redis的key的重要組成,redis的存儲實體如下圖
接下來說下計數系統的具體功能,我們對外提供的接口如下圖:
public interface BizCountService {
/**
* 計數上報接口
*
* @param request
* @return
*/
ZZOpenScfBaseResult<String> reportCount(ReportCountRequest request);
/**
* 流水記錄處理-保存日期計數的情況
*
* @param msg
* @return
*/
boolean saveDateOpt(BizCountRecordMsg msg);
/**
* 流水記錄處理-更新日期計數的情況
*
* @param heroBizCountDate
* @param msg
* @return
*/
boolean updateDateOpt(HeroBizCountDate heroBizCountDate, BizCountRecordMsg msg);
/**
* 計數總量查詢接口
*
* @param request
* @return
*/
ZZOpenScfBaseResult<Long> total(TotalRequest request);
/**
* 批量查詢總量
*
* @param request
* @return
*/
ZZOpenScfBaseResult<CountBatchResponse> countBatch(CountBatchRequest request);
/**
* 清零接口
* 總量置為0,插入一條計減當前總量的記錄;不影響歷史記錄查詢
*
* @param request
* @return 重置前的舊值
*/
ZZOpenScfBaseResult<Long> clear(ClearRequest request);
/**
* 按時間范圍計數接口
*
* @param request
* @return
*/
ZZOpenScfBaseResult<Long> countTimeBetween(CountTimeBetweenRequest request);
/**
* 批量id按時間范圍計數接口
*
* @param request
* @return
*/
ZZOpenScfBaseResult<Map<Long, Long>> batchCountTimeBetween(CountTimeBetweenBatchRequest request);
/**
* 最近n天的計數統計接口
*
* @param request
* @return
*/
ZZOpenScfBaseResult<Long> countRecent(CountRecentRequest request);
/**
* 批量-最近n天的計數統計接口
*
* @param request
* @return
*/
ZZOpenScfBaseResult<Map<Long, Long>> batchCountRecent(CountRecentBatchRequest request);
/**
* 組合計數總量查詢接口
*
* @param request
* @return
*/
Long combineTotal(CombineTotalRequest request);
}
我們整體的業務統計維度如下:
public enum BizCountType {
/**
* 用戶發帖數
*/
USER_POST_COUNT("userPostCount","用戶發帖數"),
/**
* 帖子評論數
*/
POST_REMARK_COUNT("postRemarkCount", "帖子評論數"),
/**
* 帖子點贊數
*/
POST_APPROVE_COUNT("postApproveCount", "帖子點贊數"),
/**
* 評論列表未讀總數
*/
REMARK_UNREAD_COUNT("remarkUnreadCount", "評論列表未讀總數"),
/**
* 關注列表未讀總數
*/
ATTENTION_UNREAD_COUNT("attentionUnreadCount", "關注列表未讀總數"),
/**
* 點贊列表未讀總數
*/
APPROVE_UNREAD_COUNT("approveUnreadCount", "點贊列表未讀總數"),
/**
* 評論點贊數
*/
REMARK_APPROVE_COUNT("remarkApproveCount", "評論點贊數"),
/**
* 靠譜數
*/
RELIABLE_COUNT("reliableCount", "靠譜數"),
/**
* 不靠譜數
*/
UNRELIABLE_COUNT("unreliableCount", "不靠譜數"),
/**
* 近期動態個數
*/
RECENT_COUNT("recentCount", "近期動態個數"),
/**
* 用戶回收單成交數
*/
USER_RECYCLE_ORDER_COUNT("userRecycleOrderCount", "用戶回收單成交數"),
/**
* 用戶關注數
*/
USER_ATTENTION_COUNT("userAttentionCount", "用戶關注數"),
/**
* 用戶粉絲數
*/
USER_FOLLOWER_COUNT("userFollowerCount", "用戶粉絲數"),
/**
* 用戶送檢數
*/
USER_SUBMISSION_COUNT("userSubmissionCount", "用戶送檢數"),
/**
* 報價單計數
*/
PRICE_SHEET_COUNT("priceSheetCount", "報價單計數"),
/**
* 靠譜未讀計數
*/
RELIABLE_UNREAD_COUNT("reliableUnreadCount", "靠譜未讀計數"),
/**
* 同行認證未讀計數
*/
FELLOW_CERTIFICATE_UNREAD_COUNT("fellowCertificateUnreadCount", "同行認證未讀計數"),
/**
* 用戶B2B訂單賣出數
*/
USER_B2B_ORDER_SOLD_COUNT("b2bOrderSoldCount", "用戶B2B訂單賣出計數"),
/**
* 用戶B2B訂單買入數
*/
USER_B2B_ORDER_PURCHASE_COUNT("b2bOrderPurchaseCount", "用戶B2B訂單買入計數"),
/**
* 用戶發布商品數
*/
USER_COMMODITY_PUBLISH_COUNT("userProductPublishCount", "用戶發布商品計數"),
/**
* 用戶已售商品數
*/
USER_COMMODITY_SOLD_COUNT("userProductSoldCount", "用戶已售商品計數"),
/**
* 用戶已購買商品數
*/
USER_COMMODITY_PURCHASE_COUNT("userProductPurchaseCount", "用戶已購商品計數"),
/**
* 商品詳情瀏覽數量
*/
COMMODITY_BROWSE_COUNT("commodityBrowseCount", "商品詳情瀏覽數量"),
/**
* 商品負反饋數量
*/
COMMODITY_NEGATIVE_FEEDBACK_COUNT("commodityNegativeFeedbackCount", "商品負反饋數量"),
;
/**
* 業務類型
*/
private String code;
/**
* 業務描述
*/
private String desc;
public static BizCountType getByCode(String code) {
return EnumParser.parse(BizCountType.class, BizCountType::getCode, code);
}
}
5.2.2 計數系統的上報流程
在整個上報的流程中,主要分為三個部分,獲取上報數據,處理上報數據,上報數據持久化。
獲取上報數據
數據的獲取一般有兩種方式,通過接口或通過MQ的方式,本次我們采取的是直接接口調用的方式進行處理。
之所以考慮直接用接口調用的方式進行處理,主要考慮一下幾個方面:
- 實時性高:直接接口調用通常是同步的,調用方可以立即獲得響應。
- 實現簡單:接口調用往往更容易實現,不需要額外的消息隊列中間件配置和維護,減少了系統復雜度和運維成本。對于只需要簡單請求-響應模式的服務來說,接口調用通常是更簡單直接的選擇。
- 調用順序明確:接口調用是同步的,天然保證了調用順序,特別適合一些需要嚴格順序的場景。而 MQ 消息可能會因為分區、并發消費等原因導致消息處理順序變化,不適用于需要嚴格順序的業務。
- 性能開銷小:直接調用避免了消息的中間傳輸、序列化和反序列化的開銷,通常性能更優。
- 調試方便:接口調用的流程更清晰,調試和問題排查更簡單。使用 MQ 消息時,涉及消息的存儲、轉發、消費等多個環節,排查問題時需要在消息隊列和消費端分別查看日志和狀態,調試難度更大。
處理上報數據
每個接口,會有一些邏輯上的校驗,例如,業務類型和實體id不能為空,不做其他的業務邏輯的校驗,保持計數系統的通用性,避免業務的侵入。
上報數據持久化
持久化部分主要分為兩塊,一是DB持久化,二是對于緩存的更新。
我們整體的流程是,將數據庫的變更和redis的緩存放在同一個事務中,優先更新數據庫,然后將計數流水發送mq消息,由另一個接口單獨進行流水統計,最后更新redis緩存,如果事務失敗的話,可以保證整體的一致性。至于數據的加減,由業務方來控制,加減的大小也由業務方來控制,我們只進行傻瓜式操作。具體代碼如下:
public ZZOpenScfBaseResult<String> reportCount(ReportCountRequest request) {
boolean valid = checkAndProcessReportRequest(request);
if(!valid){
return ZZOpenScfBaseResult.buildErr(-1,"參數不合法");
}
//執行插入、更新總數的邏輯
boolean locked = redissionLockHelper.tryLockBizCountTotal(request.getEntityId(), request.getBizType(), () -> {
heroBizCountTotalManager.saveOrUpdate(request.getEntityId(), request.getBizType(), request.getCount());
});
if(!locked){
log.error("lock failed,request:{}", request);
WxWarnTemplateUtil.warnOutService("計數上報-獲取鎖異常");
return ZZOpenScfBaseResult.buildErr(-1,"獲取鎖失敗");
}
//發送消息
try {
bizCountRecordProducer.sendBizCountRecordMsg(buildRecordMsg(request));
}catch (Exception e){
WxWarnTemplateUtil.warnOutService("計數上報-發送消息異常");
log.error("send report msg error, request:{}", request, e);
}
//同步總量至緩存,不影響最終一致性,且緩存有有效期,所以不阻塞流程
try {
syncTotal2Cache(request.getBizType(), request.getEntityId());
}catch (Exception e){
log.error("sync total from db error, request:{}", request, e);
WxWarnTemplateUtil.warnOutService("計數上報-同步數據至緩存異常");
}
return ZZOpenScfBaseResult.buildSucc("");
}
不得不說的技術設計細節:以空間換時間
從以上代碼中可以看出,我們在整個存儲的過程中是發送了一條MQ消息,還記得我們之前提過,我們是有時間段維度的數據統計的,這個消息就是幫助我們縮短時間段查詢響應時間的關鍵,是真正實現了我們以空間換時間的地方。具體代碼邏輯如下:
public boolean bizCountRecord(String msgId, BizCountRecordMsg body) {
log.info("bizCountRecord msgId={} body={}", msgId, GsonUtil.toJson(body));
AtomicBoolean rst = new AtomicBoolean();
boolean locked = redissionLockHelper.tryLockBizCountTotal(body.getEntityId(), body.getBizType(), () -> {
HeroBizCountDate heroBizCountDate = heroBizCountDateManager.get(body.getEntityId(), body.getBizType().getCode(), body.getTimestamp());
if(heroBizCountDate == null){
rst.set(bizCountService.saveDateOpt(body));
}
else{
rst.set(bizCountService.updateDateOpt(heroBizCountDate, body));
}
});
if(!locked){
log.info("bizCountRecord msgId={} body={} lock failed", msgId, GsonUtil.toJson(body));
WxWarnTemplateUtil.warnOutService("計數消費-獲取鎖失敗");
return false;
}
return rst.get();
}
從以上代碼可以看出,我們在接受到了消息的同時,又單獨維護了一條以業務類型和實體id為組合key的,以天為維度的數據匯總表。有了這個數據表之后,我們就有了一條天然的時間維度。如果需要查詢N天的數據,就不在需要count上報數據的流水表,可以直接通過當前的數據表,以天的問題來進行查詢。如果同一個業務類型和實體id,每天有1000的數據上報,在流水表中我們需要查詢3000條數據,而在這個以天為維度的匯總表中,我們只需要查詢3條數據。這個比例會隨著上報計數數量級的增加,越來越大,讓我們的設計方案優勢變得更加突出。
5.2.2 計數領域的讀取流程
- 非時間段取數的讀取流程:整體邏輯比較簡潔,就是先查緩存,緩存不存在就查詢DB再寫入緩存即可。
- 有時間段取數的讀取流程:代碼如下圖所有,我們會先判斷一下,這個時間段內是否有一個完成的自然日,如果沒有的話,直接查詢相關的流水表讀取數量。如果存在,先將時間段里面的自然日從我們按照天維度統計的匯總表里面讀取出來,然后其他的數據在從流水表中獲取,減少需要查詢的數量。
public Map<Long, Long> countTimeBetweenInternal(List<Long> entityIds, BizCountType bizType, Date start, Date end) {
Map<Long, Long> totalMap = Maps.newHashMapWithExpectedSize(entityIds.size());
if(!BizCommonDateUtils.containsWholeDays(start, end)){
return heroBizCountRecordManager.computeRestTime(entityIds, bizType, start, end, Maps.newHashMap());
}else{
Map<Long, BizCountDateRange> dateRangeMap = heroBizCountDateManager.getDateRange(entityIds, bizType, start, end);
Map<Long,Long> dateCountMap = heroBizCountDateManager.countDateBetween(entityIds, bizType, start, end);
Map<Long,Long> restCountMap= heroBizCountRecordManager.computeRestTime(entityIds, bizType, start, end,dateRangeMap);
entityIds.forEach(entityId -> {
Long dateCount = dateCountMap.get(entityId);
Long restCount = restCountMap.get(entityId);
if(dateCount != null && restCount != null){
totalMap.put(entityId, dateCount + restCount);
}else if(dateCount != null){
totalMap.put(entityId, dateCount);
}else if(restCount != null){
totalMap.put(entityId, restCount);
}
});
}
return totalMap;
}
6 總結與規劃
計數系統外置的架構設計也是業內比較通用的設計方案。計數系統外置的架構設計和傳統的計數系統內置的架構設計相比,它能夠顯著降低各業務在復雜計數場景下的維護成本,增強代碼功能的復用性與通用性,提高迭代效率并提升系統穩定性。獨立出來后,一旦出現異常,業務可在短時間內進行降級處理,進而減小對核心業務的影響范圍。此外,針對時間段查詢,采用以空間換時間的設計方式,能夠減少數據的查詢數量,從而提升查詢性能,縮短查詢時間。當然,我們本次受限于開發時間,也有一些不足之處:
1.時間范圍的查詢直接是DB查詢。目前的時間段查詢還是通過count表直接進行的查詢,不過目前時間段查詢數據統計需要用到的地方不多,暫時不會有性能方面的影響,后續可以通過持續迭代來進行改進。
2.沒有根據業務的使用場景來進行劃分。統計數據的使用也有讀多寫少的場景,使用緩存來保存讀多寫少的計數,其實一致性要求不高的計數,也可以先用緩存保存,然后定期刷到數據庫中,以降低數據庫的讀寫壓力。