模块:
yudao-module-statistics
关键类/脚本:TradeStatisticsServiceImpl、TradeOrderStatisticsServiceImpl#fillTrendGaps、22-pro-statistics-performance.sql
关键词:统计聚合表设计、唯一键防重、趋势图补零、商城数据库设计
摘要
趋势图"缺日"、重复统计行、补跑失控,是电商统计系统的三大经典事故。本文介绍久滴直播电商系统在 P0 统计修复中的落库层设计:(tenant_id, time)唯一键从 DB 层杜绝重复、每日 Job 幂等落库、fillTrendGaps游标补零保证 X 轴连续,以及补跑接口的天数上限防护。
技术背景
久滴的统计架构是"T+1 落库 + 今日实时计数"双层模型:
- 昨日及以前:由
tradeStatisticsJob每日 00:30 聚合trade_order等表写入trade_statistics,看板读落库表,毫秒级返回; - 今日:由 Redis 实时计数器承担(见系列文章 01)。
这一模型曾暴露三个 P0 问题:
- 重复行:
product_statistics实测存在 16 组(tenant_id, time)重复数据——应用层 check-then-act 防重在多实例部署/Job 重跑下失效; - 趋势缺日:
GROUP BY DATE(pay_time)对无订单日期直接不产出行,前端折线图断裂; - 对照错位:今日 vs 昨日的对照曲线按
date字段匹配,两个区间日期本就不重叠,匹配永远落空。
实现方案
1. 唯一键:把防重从应用层下沉到 DB 层
22-pro-statistics-performance.sql核心变更:
-- trade_statistics 无历史重复,直接添加ALTERTABLEtrade_statisticsADDUNIQUEKEYuk_tenant_time(tenant_id,time);-- product_statistics 先去重(保留 id 最小的一条),再加唯一键DELETEpFROMproduct_statistics pINNERJOIN(SELECTMIN(id)ASkeep_id,tenant_id,timeFROMproduct_statisticsGROUPBYtenant_id,timeHAVINGCOUNT(*)>1)dupONp.tenant_id=dup.tenant_idANDp.time=dup.timeANDp.id<>dup.keep_id;ALTERTABLEproduct_statisticsADDUNIQUEKEYuk_tenant_time(tenant_id,time);设计权衡:
- 为什么唯一键含 tenant_id 前缀:多租户拦截器为每条 SQL 追加
tenant_id条件,前缀索引/唯一键能精确定位单租户区段; - 为什么允许冲突失败而不做 upsert:同一天同一口径的重复统计数值必然一致,唯一键冲突即说明 Job 重跑,直接失败是最安全的幂等语义;
- 先去重再加键的顺序不可颠倒,否则 ALTER 会因存量重复报
Duplicate entry。
2. 补跑接口与天数上限
历史数据初始化或 Job 中断后需补跑,statisticsTrade(days)支持按天数回溯:
/** 补跑天数上限:防止误传超大参数长时间占用线程 */privatestaticfinalintMAX_STATISTICS_DAYS=1095;@OverridepublicStringstatisticsTrade(Integerdays){if(days>MAX_STATISTICS_DAYS){thrownewIllegalArgumentException("交易统计补跑天数不能超过 "+MAX_STATISTICS_DAYS+" 天");}// 逐日回溯:IntStream.rangeClosed(1, days) 对每一天调用单日统计并汇总结果...}上限 1095 天(3 年)的来源:覆盖业务需要的最长回溯窗口,同时避免误传days=99999让单个请求占用线程数小时拖垮服务。实际补跑经验:930 天历史数据通过管理后台"手动执行 Job + 传参"一次完成。
3. fillTrendGaps:游标补零保证 X 轴连续
趋势接口返回前统一过补零函数,按天/月游标生成完整日期序列:
privateList<TradeOrderTrendRespVO>fillTrendGaps(List<TradeOrderTrendRespVO>list,LocalDateTimebeginTime,LocalDateTimeendTime,booleanbyMonth){// 1. 查询结果转 Map(date -> VO),重复日期取第一条兜底Map<String,TradeOrderTrendRespVO>map=list.stream().collect(Collectors.toMap(TradeOrderTrendRespVO::getDate,Function.identity(),(a,b)->a));// 2. 按天/月游标从 beginTime 走到 endTime,生成完整日期序列// 月粒度 yyyy-MM(对齐 GROUP BY DATE_FORMAT('%Y-%m')),日粒度 yyyy-MM-ddList<String>keys=/* 游标逐日/逐月推进生成的全部日期 */;// 3. 逐 key 查 Map,缺失的补零行(orderPayCount=0, orderPayPrice=0)returnkeys.stream().map(key->map.getOrDefault(key,newTradeOrderTrendRespVO().setDate(key).setOrderPayCount(0).setOrderPayPrice(0))).collect(Collectors.toList());}要点:
- 补零在服务端而非前端:前端无法判断"缺失"与"为 0",服务端补零后接口契约变为"返回区间内连续完整序列";
(a, b) -> a合并策略:即使底层查询意外返回重复日期,取第一条兜底,不抛异常;- 月粒度用
yyyy-MM格式:与GROUP BY DATE_FORMAT(pay_time, '%Y-%m')的输出格式严格对齐,type=365(YEAR)时自动切换。
4. 对照数据的序号对齐(修复隐蔽 Bug)
对照场景(今日 vs 昨日、本周 vs 上周)两个区间的日期必然不同,原实现按date查找对照点永远返回 null。修复后先对两侧分别fillTrendGaps,再按序号对齐:当前区间第 N 天对应对照区间第 N 天。补零在此承担了第二职责——它让两个不等长序列长度一致,序号对齐才有意义。
注意事项
- 加索引/唯一键选业务低峰期执行:
trade_order是大表,DDL 期间会锁表(MySQL 8.0 的 instant/online DDL 对加索引较友好,但仍建议避开高峰); - 验证 SQL:
SHOWINDEXFROMtrade_statistics;-- 应见 uk_tenant_timeSELECTtenant_id,time,COUNT(*)cFROMproduct_statisticsGROUPBYtenant_id,timeHAVINGc>1;-- 应为空集 - 补跑前先确认口径:补跑按当前代码口径重算,若口径曾变更,历史区间会被"新口径"覆盖——先在对账 Job 通过后小范围试跑;
- 不要手工 INSERT 统计表:绕过 Job 插入的行若与后续 Job 冲突会唯一键报错,一律通过"手动执行 Job 传参"补数。
关于久滴直播电商系统
JiuDi Live-Mall Service— 面向直播电商场景的全栈后端 Pro 版解决方案。久滴直播电商系统 —— 基于芋道框架深度定制的 SaaS 级直播电商解决方案,支持多租户、实时统计与腾讯云直播集成。
🎥久滴成河,直播成商·Drop by drop, live commerce flows.