苍穹外卖统计模块实战:MySQL聚合查询与ECharts联调
苍穹外卖这个项目我做到第10天的时候任务突然从“做功能”变成了“看数据”。前面几天还在写员工管理、菜品分类、购物车和下单那一套到了第10天核心就一件事把后台的统计模块做出来让运营能按时间范围直观地看到营业额、用户增长、订单情况和菜品销量排名。这里说的统计模块指的不只是一句SQL而是从MySQL聚合查询、Spring Boot接口到前端ECharts图表的完整链路。当天做完以后我觉得这也是整个项目里最值得拿出来复盘的一环因为数据统计看似简单坑都藏在各种边界条件里。如果你也正在做类似的餐饮管理后台项目或者正准备接触数据可视化模块这篇主要写给两类人一类是刚把增删改查写完、想看看统计报表怎么设计的新手另一类是用过ECharts但总在图表联调上出问题的人。我会把第10天的功能拆解、后端实现、前端联调和踩坑记录一次说清楚。1. 数据统计模块到底在统计什么1.1 工作台页面的需求场景先把这个模块的功能范围讲清楚。后台管理系统里运营或店长登录以后第一眼看到的往往不是订单列表而是一个“工作台”首页上面摆着今日营业额、待接单数量、待配送数量等核心指标。第10天做的事情就是在工作台基础上加了一段“按时间范围查询”的图表统计能力。具体到页面常见的是这样几个区块顶部放四个统计卡片今日营业额、今日订单数、新增用户数、有效订单完成率。中间是一张营业额趋势图默认展示最近7天。下面可能是用户增长曲线或者订单状态分布饼图。再往下是菜品销量排行Top10。前端通常用ECharts来画折线图、柱状图和饼图后端负责提供结构化的JSON数据。也就是说第10天表面上是“写统计接口”实际上要打通三层MySQL里的聚合查询、Controller返回的数据结构、前端图表接收的格式。这里我建议先别急着写代码而是先把“要算哪些指标”列清楚。以苍穹外卖这个项目为例核心指标无非四个营业额、用户数、订单量、菜品销量。但这四个指标不能随便查必须确定数据口径否则一个指标有偏差整张图表都是错的。1.2 统计口径哪些订单算数数据统计最容易翻车的地方不是SQL不会写而是“口径没对齐”。比如营业额你到底是统计所有订单的总金额还是只统计“已完成”的订单如果用户下单后取消或者支付后申请退款这单的钱还能算营业额吗我当时的做法是先看项目的订单状态枚举把状态码梳理一遍。常见外卖项目里订单状态可能有1待付款、2待接单、3已接单、4配送中、5已完成、6已取消。所谓“有效订单”一般指已经完成的订单也就是状态为5的记录。营业额统计、订单完成率、菜品销量Top10全都基于这个口径。有个容易忽略的细节订单表里的金额字段可能会分“订单金额”和“实收金额”。如果包含优惠、满减、配送费减免两者就会有偏差。营业额应该以用户实际支付的金额为准也就是实收金额不要拿订单原价去算。这个细节在功能演示时可能看不出问题但运营对账的时候一定会发现。所以我在做接口之前先把这些规则写成了注释放在Service方法头上/** * 营业额统计口径 * 1. 只统计状态为“已完成(5)”的订单 * 2. 金额取实收金额字段 * 3. 时间范围包含开始日期和结束日期按自然日分组 */别嫌这一步麻烦等到联调后期再去改口径改动面会大得多。2. 后端统计接口从SQL到返回结构2.1 营业额统计接口的完整实现先看后端最核心的营业额统计接口。前端说“我要查1月1日到1月7日每天营业额”后端就要返回两个对齐的数组一个是日期列表一个是营业额列表。我先定Controller接口参数直接用LocalDate接收方便后面做日期计算GetMapping(/statistics/turnover) public ResultTurnoverReportDTO turnover( RequestParam LocalDate begin, RequestParam LocalDate end) { ListLocalDate dateList getDateList(begin, end); ListBigDecimal turnoverList statisticsService.getTurnoverList(begin, end, dateList); return Result.success(new TurnoverReportDTO(dateList, turnoverList)); }Service里要做的第一件事是先把日期范围内的每一天都生成出来。这一步必须做因为数据库里可能某一天没有任何订单SQL聚合结果里压根没有这一天的记录但图表上仍然要显示这一天的营业额值为0。日期生成逻辑很简单private ListLocalDate getDateList(LocalDate begin, LocalDate end) { ListLocalDate dateList new ArrayList(); LocalDate current begin; while (!current.isAfter(end)) { dateList.add(current); current current.plusDays(1); } return dateList; }然后写SQL查询。我建议这种聚合SQL放到Mapper XML里而不是用注解写在Java代码上因为以后改动概率很大XML改起来不用重新编译可读性也更好。select idgetDailyTurnover resultTypemap select date_format(create_time, %Y-%m-%d) as date, sum(amount) as turnover from orders where status 5 and create_time gt; #{begin} and create_time lt; DATE_ADD(#{end}, INTERVAL 1 DAY) group by date_format(create_time, %Y-%m-%d) order by date /select这里有个非常重要的细节查询条件是左闭右开也就是 begin和 end 1天。为什么不用 end因为create_time是datetime类型如果传进来的end是2025-01-07那么create_time 2025-01-07只包含了1月7日0点0分0秒以前的数据当天的订单全都被漏掉了。这种边界坑在统计模块里属于必踩项我后面还会专门复述。Service层拿到Mapper返回的List之后把结果转成Map结构再遍历日期列表补齐0值MapLocalDate, BigDecimal map new HashMap(); for (MapString, Object row : rows) { LocalDate date LocalDate.parse((String) row.get(date)); BigDecimal turnover new BigDecimal(row.get(turnover).toString()); map.put(date, turnover); } ListBigDecimal turnoverList new ArrayList(); for (LocalDate date : dateList) { turnoverList.add(map.getOrDefault(date, BigDecimal.ZERO)); }这样前端拿到的就是长度一致的两个数组第几个日期对应第几个营业数据不会错位。2.2 用户统计和订单统计的SQL差异用户统计要返回两个维度每日新增用户数以及截止到每天的总用户数。每日新增用户好查就是用户在当天第一次创建账号的数量select date(create_time) as date, count(*) as cnt from user where create_time #{begin} and create_time DATE_ADD(#{end}, INTERVAL 1 DAY) group by date(create_time)难点在于“总用户数”。如果你打算在Java里写一个循环每天执行一次count(*)那数据量小的时候没问题但接口响应时间会很难看而且写起来特别蠢。我当时直接让SQL把累计结果也算了select date, sum(cnt) over (order by date) as total from ( select date(create_time) as date, count(*) as cnt from user where create_time #{begin} and create_time DATE_ADD(#{end}, INTERVAL 1 DAY) group by date(create_time) ) t order by dateMySQL 8 支持窗口函数这个写法很干净。查询结果里每一行是“某天新增多少、截止这天一共多少”前端直接画两条折线就行。订单统计稍微综合一点。一般的需求是看每天“总订单数”和“有效订单数”顺便算一下完成率。一条SQL就能搞定select date_format(create_time, %Y-%m-%d) as date, count(*) as totalOrders, sum(case when status 5 then 1 else 0 end) as validOrders from orders where create_time #{begin} and create_time DATE_ADD(#{end}, INTERVAL 1 DAY) group by date_format(create_time, %Y-%m-%d) order by date完成率不要在SQL里除因为可能出现某天总订单数为0的情况除数为0会直接报错。放到Java里判断if (totalOrders 0) { completionRate BigDecimal.ZERO; } else { completionRate BigDecimal.valueOf(validOrders) .divide(BigDecimal.valueOf(totalOrders), 4, RoundingMode.HALF_UP); }2.3 菜品销量Top10的聚合方式菜品销量排行要侧重“商品维度”常见做法是拿订单明细表去聚合。需要注意两点一是只能统计已完成订单里的商品否则取消的订单也会把销量算进去二是如果同一个菜品在多笔订单里出现要按菜品名称分组求和。select idgetTop10Dishes resultTypemap select od.name as name, sum(od.number) as sales from order_detail od inner join orders o on od.order_id o.id where o.status 5 and o.create_time gt; #{begin} and o.create_time lt; DATE_ADD(#{end}, INTERVAL 1 DAY) group by od.name order by sum(od.number) desc limit 10 /select为什么按od.name分组而不是od.dish_id因为如果菜品被下架、删除或修改过名称订单明细里仍然应该保留下单那一刻的名称。按名称分组虽然简单但在“同一菜品改过名”的情况下会把数据分成两条严格来说应该按菜品id分组。我做项目用的是按od.name因为功能演示要求不高而且明细表在设计时已经冗余存了商品名称。真实商业项目里我建议优先用goods_id这类稳定字段去分组。3. 前端图表联调ECharts如何接住这些数据3.1 后端返回结构怎么设计最顺手统计接口的返回结构直接影响前端写起来顺不顺手。我见过有人把接口设计成[{date:2025-01-01, value:123}, ...]前端也能用但ECharts的x轴数据还得单独抽出来多一道转换。最简单的方式是后端直接给两个对齐的数组{ code: 1, data: { dateList: [2025-01-01, 2025-01-02, 2025-01-03], turnoverList: [120.00, 0.00, 356.50] } }前端拿到以后连数据加工都不用直接这样绑定function loadTurnoverChart() { axios.get(/admin/statistics/turnover, { params: { begin: dateRange.begin, end: dateRange.end } }).then(res { const data res.data; turnoverChart.setOption({ xAxis: { data: data.dateList }, series: [{ data: data.turnoverList }] }); }); }日期格式统一用yyyy-MM-dd别返回带时间戳或带T的ISO字符串否则ECharts的类目轴会显示得很奇怪。3.2 默认时间范围和空白数据处理页面一打开的时候数据是空的前端需要先给一个默认时间范围。通常取“最近7天”也就是从今天往前推6天。这里有一个新手容易踩的点日期选择器用的组件返回值可能是字符串、Date对象或时间戳一定要统一转换后再传给后端。我当时写了一个比较稳妥的方法function getDefaultRange() { const end new Date(); const start new Date(); start.setDate(end.getDate() - 6); return { begin: formatDate(start), end: formatDate(end) }; } function formatDate(date) { const y date.getFullYear(); const m String(date.getMonth() 1).padStart(2, 0); const d String(date.getDate()).padStart(2, 0); return ${y}-${m}-${d}; }另外图表上没有数据的时候比如刚上线还没有任何订单后端返回的列表可能全是0。对于折线图、柱状图来说没问题直接显示0即可但饼图会有麻烦如果所有值都是0ECharts通常不会渲染页面就会空白。处理办法是在前端判断一下汇总值为0的时候给出一个“暂无数据”的提示而不是干等一张空图。3.3 图表刷新和销毁的细节关于ECharts实例我发现很多经验不足的人容易犯一个错在组件里初始化了一个图表实例以后整个页面到处用同一个变量切换日期范围以后图表不更新也不调用setOption的覆盖逻辑。这里我的经验是每个图表容器单独维护一个实例切换查询条件时统一走同一个setOption而且要带上notMerge: true保证旧数据被完整清掉chart.setOption(option, true);页面销毁时记得调用chart.dispose()否则页面频繁切换会出现内存泄漏或图表重叠的问题。4. 联调过程里我踩过的几个真实问题4.1 时间边界导致统计少了当天数据这个问题我在上面提到过但因为它太典型值得单独展开。当时联调时前端选择1月1日到1月7日结果1月7日当天无论怎么下单营业额统计里都没有数据。我排查了一圈发现问题出在SQL条件写成了where create_time #{begin} and create_time #{end}这个条件的实际效果是查询到1月7日0点0分0秒就截止了。当天上午10点的订单因为create_time 2025-01-07 00:00:00全部被过滤掉。解决办法就是统一改成左闭右开where create_time #{begin} and create_time DATE_ADD(#{end}, INTERVAL 1 DAY)从那以后我写所有时间范围查询都默认左闭右开这个习惯帮我避开了一整类bug。4.2 MySQL时区引起的统计偏差还有一个隐藏很深的问题就是时区。数据表里有一条订单创建时间是中午12点但统计结果显示它跑到了前一天。这种情况发生在数据库和服务端连接时区设置不一致的时候。我当时检查了JDBC连接串发现配置里没写serverTimezoneMySQL默认用的是服务器本地时区而Java应用侧用的是一套默认时区两边一换算就偏了。解决办法很直接在数据库连接串里显式指定spring.datasource.urljdbc:mysql://localhost:3306/sky_take_out?serverTimezoneAsia/ShanghaiuseSSLfalse如果是Docker部署的MySQL容器里的默认时区也可能是UTC那就要在容器启动时加时区参数或者在配置里统一指定。这个问题在本地开发时不容易暴露一旦部署到不同环境的服务器上统计结果就可能出现莫名其妙的偏差。4.3 累计用户数SQL写法的优化过程我第一次实现“总用户数”的时候图省事写了一个“每天查一次总数”的循环。测试时数据量小感觉还行后来导入了模拟数据接口一下子卡了好几秒。因为每查一天都要全表扫一次User表7天就是7次30天就是30次。后来改成上面说的窗口函数一次查询把每日新增和累计都算出来响应时间从几秒降到了几十毫秒。这个优化的核心思路是能用SQL在数据库里完成的聚合统计就不要把明细数据拉到Java里再算。统计模块尤其要注意这点因为数据量一大Java层循环做累加的性能完全不可控。4.4 图表容器宽度为0的经典坑还有一个前端问题卡了我将近一个小时。图表用在弹窗或Tab页里时初始化执行的时候容器还没有显示出来宽度直接取到0结果图总是画不出来或者变形。我遇到的具体场景是一个页面上有两个Tab第一个Tab是表格第二个Tab放图表。进入页面时ECharts已经初始化了但第二个Tab还没渲染容器宽高都是0。等切换到第二个Tab时图表已经错过了绘制时机。解决办法有两个看场景选用ECharts实例初始化放在容器真正显示出来之后比如Tab切换的回调里。容器已经初始化但宽度变化了就调用chart.resize()重新计算。我在代码里做了个封装切换Tab时统一调一下this.$nextTick(() { turnoverChart.resize(); userChart.resize(); });4.5 排查问题用到的几个实用手段联调时遇到“数字不对”的情况我的排查顺序一般是先确认口径再查SQL最后看前端数据转换。一个特别好用的手段是在开发环境临时开启MyBatis的SQL日志把实际执行的SQL和控制台打印出来的SQL对比看看传入的时间参数到底拼接成了什么。反正我遇到过不止一次Controller层传进来的是LocalDate一不留神被转成了带时分秒的字符串导致SQL查询条件完全变了。另一个手段是先把后端接口返回的JSON直接贴到浏览器或Postman里看确认后端数据没问题再去找前端问题。前后端联调时最忌讳两边同时怀疑规范的做法是先固定一端再排查另一端。我把这些经验整理成一个速查表算是给后面做统计模块的朋友一个参考问题现象可能原因解决方向当天数据缺失时间范围用了 end改成 end 1天数据整体偏到前一天时区不一致连接串指定serverTimezone图表不显示容器宽高为0容器显示后再初始化或调用resize()某个日期没有数据数据库当天无记录后端补齐0值完成率超过100%分子分母口径不一致分子必须是已完成订单数累计用户数计算慢Java循环多次查询用窗口函数一次查出5. 统计模块做完整之后还能加什么第10天要求的功能做完以后页面能看、数据能查、图表能跳已经算达标了。但如果想把这套模块做得更有价值我额外做了几个小扩展成本都不高。第一个是给统计接口加统一的参数校验。搜索条件里的开始日期不能晚于结束日期时间跨度不能超过一年否则就抛出异常。这个校验看起来琐碎但能防止用户传了一个超大时间范围把数据库拖垮。第二个是考虑加一层缓存。统计数据的实时性没那么高一个运营页面不需要精确到分钟级。当天之前的数据基本不会变了完全可以缓存起来只有“今天”的数据需要实时查。这种思路在真实项目里很常见不过第10天阶段先不急着上Redis保持逻辑简单更重要。第三个是思考要不要做门店维度。苍穹外卖这个项目本身是单体门店统计不需要区分门店。但真实的外卖系统通常会有多个门店营业额、用户、订单统计都要支持按门店过滤。这时候接口里的查询条件就得加一个shopIdSQL里也多一个分组维度。虽然第10天没要求但把这个思路预留到设计里后面扩展起来会省很多事。说回第10天本身。统计模块和前面几天的增删改查不太一样它更强调对业务的理解而不是纯粹的接口堆砌。同样一张订单表不同人写的统计SQL可能结果完全不一样差别就在口径上。我个人在做完这个模块以后最大的体会是画图永远不是重点重点是让图上的每一个数字都经得起推敲。最后再分享一个小技巧联调这种带时间范围的接口时不要把日期参数在代码里传来传去而是统一在Controller层接收LocalDate在SQL里用左闭右开的条件表达。这样既不会踩时区坑也不会漏掉当天数据。把这个习惯固定下来你后面做报表、统计、对账类功能时都会少很多折腾。