如果你也正在做一个前后端分离的外卖项目,那“苍穹外卖--day11”这个节点一定不陌生。到了第11天,业务闭环基本已经打通:员工登录、分类菜品、套餐管理、购物车、订单流转这些模块都有了,接下来要面对的就不再是单纯的增删改查,而是一堆“算钱、算人、算订单”的统计报表,还有让人又爱又恨的Excel导出。说白了,就是从“能不能跑”过渡到“好不好看、准不准、扛不扛造”的阶段。
这篇文章把day11里最核心的几件事拆开聊一遍:统计口径怎么定、定时任务怎么落、报表接口怎么写、后端导出Excel怎么搞,以及我在这个阶段踩过的一堆坑。无论你是自己练手做的毕业设计,还是公司里真的在搭外卖管理后台,看完至少能少走三天的弯路。
1. day11在项目里的真实定位:功能收尾,报表时代开启
1.1 为什么这一天不是继续堆业务功能
如果只看功能列表,day11很容易被误以为只是“再加几个查询接口”。但真正接手项目的人会发现,这一天是整个项目从“可用”到“好用”的分水岭。前面做的员工管理、菜品管理、订单管理,本质上是把数据正确入库;而从数据统计开始,需求方关心的变成了“最近一个月营业额是多少”“哪些菜卖得最好”,这些问题的共同点是:要跨表、要聚合、要按时间切片,还要处理各种各样的边界情况。
苍穹外卖做到第11天的时候,订单、用户、菜品这几张核心表里的数据已经积累起来了。没有了数据,统计就是空中楼阁;但有了数据之后,统计模块对查询效率和代码组织的要求又远高于普通列表接口。所以这天的任务量看起来不大,难度却一点不低。
1.2 一张小票揭开的需求全景
我们可以把外卖平台里每天发生的一笔订单,拆成一条完整的数据链路:顾客在用户端选菜、提交订单,系统生成订单主表和订单明细表;支付成功后订单状态流转;商家端接单、出餐、送达;顾客确认完成。等到第11天,最直观的需求就是把这条链路上所有环节的数字拉出来给老板看。
所以day11的核心任务通常包含四块:
- 营业额统计:按日期区间统计已完成的订单金额和订单数;
- 用户统计:统计新增用户数、用户总量,最好能按天画出趋势;
- 订单统计:统计有效订单数、订单完成率、平均客单价;
- 销量排名Top10:按菜品维度统计销量最高的前十个菜。
这四块并不是拍脑袋想出来的,它对应的是管理者最关心的经营四问:赚了多少、来了多少新客、订单成没成、什么东西好卖。明白了这个背景,后面写代码时就不会只看SQL怎么查,而是会下意识地检查口径是否合理。
1.3 初始方案为什么不能用“实时查表”
第一次做统计模块的人,最容易掉进的坑是:前端要一个数,后端就现写一条select,把订单表从头到尾聚合一遍。数据量小的时候没问题,数据量一旦上来,或者查询区间跨了好几个月,这种写法会把数据库拖垮,也会让接口的响应时间变得忽快忽慢。
day11里更适合的做法是“统计结果缓存 + 必要的时候实时计算”。这个思路类似于超市收银:不会每次顾客问“今天卖了多少”的时候,才去把所有小票重新加一遍,而是每天晚上闭店之后把当天的账算好,白天直接看汇总数字。落到系统里,就是用定时任务在凌晨把昨天的统计结果算好,存入缓存或者独立的汇总表;用户白天打开报表页面时,接口直接返回已经算好的数据,个别需要实时刷新的维度再单独查库。
这样一个“先离线汇总、再按需实时”的二级方案,既保证了高峰时段的体验,又避免了动态大查询对核心订单库的冲击。具体的落地细节,下一节展开说。
2. 统计口径设计:算得出来的数字未必是老板要的数字
2.1 先把“营业收入”的定义定死
写代码之前,我把整个模块的口令向产品和运营同事重新对了一遍。最典型的坑在“营业额”三个字上:到底是订单实付金额的总和,还是扣除退款后的净收入?是包含了配送费,还是只算菜品金额?
在苍穹外卖这种场景里,我最终采用的是:统计“已完成”状态订单的实付金额,并且对退款单做了扣除。体现到订单表上,就是先通过status字段过滤掉无效订单,再用amount字段做sum。之所以不统计“待支付”或“已取消”的订单,是因为这些订单没有给平台带来真实收入,算进去只会让报表虚高。
口径定好之后,查询接口的核心SQL就非常清晰了。比如要查某一天到某一天的营业额,大致是这样:
select date(create_time) as stat_date, sum(amount) as turnover, count(id) as order_count from orders where status = 5 and create_time >= #{beginDate} and create_time < DATE_ADD(#{endDate}, INTERVAL 1 DAY) group by date(create_time) order by stat_date;这里有个容易被忽略的细节:结束日期用“小于第二天零点”而不是“小于等于当天23:59:59”。因为create_time如果带时分秒,直接用<=很容易漏掉最后一秒内的订单。这个习惯建议从一开始就养成,后面所有时间范围查询都能受益。
2.2 用户统计里的“新增用户”是个相对概念
用户统计比营业额统计更容易出问题,因为“用户数”在不同业务语境下定义完全不同。day11里我同时做了两个指标:一个是“今日新增用户”,一个是“总用户数”。
“今日新增用户”的实现依赖用户表里的创建时间字段:
select count(id) from user where create_time >= #{beginDate} and create_time < DATE_ADD(#{endDate}, INTERVAL 1 DAY);“总用户数”则是固定口径下的全表count,不需要时间条件。真正麻烦的是,产品方可能会要求“按天展示新增用户曲线”,这时候如果用一条分组查询硬怼,会得到很多没有新增用户的天,前端画图时就会出现断点。我的处理方式是把一整段日期的每一天都先在内存里生成一个日期序列,再用查询结果去填充;查不到的日期补0。这样前端拿到的数据永远是连续的时间轴,折线图画出来才好看。
2.3 订单统计和完成率为什么要分成两步算
订单统计里最核心的指标是“有效订单数”“订单完成率”和“平均客单价”。有效订单我沿用“已完成”状态的订单;完成率则是有效订单除以总订单数,而不是除以某种中间状态。
这里的关键在于,完成率的分母必须提前定义。是用当日所有订单做分母,还是只算支付成功的订单?我最终选择的是“当日有效订单数 / 当日总订单数”,因为对老板来说,顾客下单了但最终没完成,就是一笔损失,应该被暴露出来。如果分母只算支付订单,完成率永远是接近满分的数字,反而失去了监控意义。
平均客单价的算法相对简单,直接拿有效订单金额总和除以有效订单数即可。由于涉及除法,SQL里要注意decimal类型的使用,避免两个整数相除直接截断为0。在Java侧接口返回时,也建议用字符串或者BigDecimal序列化,防止前端JavaScript丢精度。
2.4 销量Top10:看起来简单,细节不能漏
销量排名的SQL一开始我写得很随意,以为就是按数量sum排序然后limit 10。真正对完需求才发现,这里有两个坑:第一,排名按的是“份数”还是“销售额”,老板可能两个都想看;第二,排序维度要不要区分口味、规格,比如“大杯珍珠奶茶”和“中杯珍珠奶茶”算不算同一个菜品。
苍穹外卖里菜品和订单明细的关系是一对多:订单明细表里存着菜品id、菜品名称、份数、金额。做销量排名时,我先按菜品id分组,sum份数作为销量,sum金额作为销售额,然后order by销量desc,limit 10。
select od.dish_id, od.dish_name, sum(od.number) as sales, sum(od.amount) as amount from order_detail od inner join orders o on od.order_id = o.id where o.status = 5 and o.create_time >= #{beginDate} and o.create_time < DATE_ADD(#{endDate}, INTERVAL 1 DAY) group by od.dish_id, od.dish_name order by sales desc limit 10;需要注意,如果用dish_name做分组条件,必须保证同一菜品在不同订单里的名称完全一致;一旦运营在后台改了菜品名,历史数据里的名称还是旧值,榜单就会出现两个“同菜异名”的条目。所以更稳妥的做法是以dish_id为唯一分组键,dish_name只是展示字段,分组后取最新名称。这个细节我在第11天吃过亏,后面在“问题清单”里会再提。
3. 定时任务与缓存:让报表在高峰期也能秒开
3.1 凌晨结算任务的设计
统计结果要是现算,白天店里单量一大,数据库必然遭殃。day11引入定时任务的做法是每天凌晨1点执行一次前一天的统计数据汇总,把结果写入一张独立的统计表或者Redis缓存里。
选用Spring自带的@Scheduled注解就足够,不需要引入额外任务调度框架。核心代码如下:
@Component @Slf4j public class ReportDataTask { @Scheduled(cron = "0 0 1 * * ?") public void generateYesterdayReport() { LocalDate yesterday = LocalDate.now().minusDays(1); // 调用统计服务,计算营业额、用户数、订单数、Top10 reportService.generateStatistic(yesterday); log.info("生成昨日统计完成,日期: {}", yesterday); } }定时任务本身不难,难的是保证幂等。假如任务跑了一半宕机了,第二天重启后会不会重复执行?我的处理是在统计任务里加了一个“日期是否已生成”的判断,如果目标日期在结果表里已经有数据,就先删除再重算,保证同一份数据不会被叠加两次。
3.2 缓存选型与更新策略
统计结果我选择放在Redis中,用日期作为key的一部分,例如report:turnover:2025-01-01,value直接放JSON字符串。这样管理后台查询时,只要根据日期拼出key就能拿到结果,不需要每次穿透到MySQL。
更新策略上,凌晨任务只负责写入“昨天”的key,而白天如果运营修改了某些订单状态(比如补录退款),就需要在订单状态变更的地方主动删除对应日期的缓存,强制下次查询时重算。这个“删除缓存而不是更新缓存”的做法,主要是为了避免并发下两个线程同时写,导致出现旧值盖新值的问题。简单说,拿不准的时候就删key,让查询侧去加载,最安全。
3.3 统计模块的前后端约定
接口设计上,我建议把日期范围作为统一入参,返回结构按天展开。比如营业额统计接口的返回格式是这样:
{ "dateList": ["2025-01-01", "2025-01-02"], "turnoverList": [3200.50, 4150.75], "orderCountList": [120, 153] }这种扁平结构是专门为ECharts这类图表库准备的。前端拿到后不需要二次加工,直接拿两个数组分别对应x轴和y轴。实际联调中,我发现很多前端同事希望后端返回的是“左对齐的数组结构”,而不是“一个map里嵌套对象数组”,因为前者可以直接setOption。所以接口设计不能只从后端方便出发,还要考虑调用方的使用成本。
3.4 大时间跨度的查询容错
有的报表会支持“最近12个月”甚至“任意日期区间”,这时候如果缓存key按天粒度存,查询时就需要批量拼接key,然后从Redis里一次性取出来。为了避免对Redis发起大量单点get,我习惯用pipeline或者mget批量读取,能在一次网络往返里拿到全部数据。
如果某天的key不存在,比如系统还没上线那天,就用默认的零值补齐。这里要特别小心空指针:JSON反序列化时如果value是null,要主动转成“0”而不是让接下来的计算直接抛异常。这类问题在开发环境通常跑不出来,但生产环境特别容易在月末、年底的大区间查询里爆发。
4. Excel报表导出:后端顺手还是前端顺便,我选后端
4.1 为什么导出要放后端而不是前端
很多前端同事会习惯性认为,页面上的表格已经有了,导出Excel不就是把DOM里的数据复制一遍吗?但实际业务里,报表导出的数据量往往比表格大得多,而且需要包含多个Sheet、汇总行、表头样式。放在前端处理,浏览器内存很容易被撑爆,导出来的格式也难以统一。
所以day11的Excel导出我放在后端接口里。后端根据前端传的起止日期,从统计接口里查出数据,再通过工具类写入Excel文件流,最后以附件形式返回给浏览器。用户点击“导出”按钮,本质上是在下载一个后端生成好的.xlsx文件。
4.2 导出接口的完整实现流程
我用的是EasyExcel,相比原生的Apache POI,它在内存占用和数据量处理上更友好。一个最简单的导出动作分成三步:查数据、写Excel、设置响应头返回。
@GetMapping("/export") public void export(HttpServletResponse response, @RequestParam String begin, @RequestParam String end) throws IOException { List<ReportVO> data = reportService.queryRange(begin, end); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("utf-8"); String fileName = URLEncoder.encode("营业额报表_" + begin + "_" + end, "UTF-8"); response.setHeader("Content-disposition", "attachment;filename=" + fileName + ".xlsx"); EasyExcel.write(response.getOutputStream(), ReportVO.class) .sheet("营业额统计") .doWrite(data); }这里有两个细节值得说一下。一是响应头里的文件名必须URLEncoder处理,否则浏览器端中文名会乱码;二是文件名的后缀里我写的是.xlsx,和Content-Type要保持一致,不能出现“明明生成的是Excel,下载下来却打不开”的情况。
4.3 样式与表头的美化技巧
如果只是一行一行的数据写出来,领导可能会觉得“不够专业”。EasyExcel支持在注解里直接声明列名,比如:
@ExcelProperty(value = "日期", index = 0) private String date; @ExcelProperty(value = "营业额", index = 1) private BigDecimal turnover;要注意的是,@ExcelProperty的value会直接成为表头,所以字段的命名要尽量口语化,别把turnover这种英文直接暴露给使用者。对于汇总行,我选择在数据列表末尾额外追加一条固定记录,再通过行合并样式让它显示在表格底部。这个方法不难,但能让报表看起来完整很多。
4.4 大数据量导出时的内存控制
如果统计区间横跨一整年,数据可能达到几千行甚至上万行,这时候一次性把所有数据加载到内存再写Excel,很容易导致接口卡顿。EasyExcel提供了分批读取和写入的能力,真正实现方式是“数据分批查询,逐批doWrite”。
ExcelWriter writer = EasyExcel.write(response.getOutputStream()).build(); WriteSheet sheet = EasyExcel.writerSheet("统计数据").build(); int page = 1; while (true) { List<ReportVO> pageData = reportService.queryPage(begin, end, page, 500); if (pageData.isEmpty()) break; writer.write(pageData, sheet); page++; } writer.finish();这种写法把内存压力从“全量”降到了“每批500条”,在数据量大时体验差别非常明显。实际项目中我做过压测,同样的数据量,分批导出比一次性导出节省约70%的峰值内存,接口超时率也低了很多。
5. 常见问题与排查技巧实录
5.1 金额精度丢失,报表差出几分钱
统计中最常见、也最容易被忽视的问题是金额类型。数据库字段如果用float或者double,经过多次sum之后会出现0.01甚至0.1的误差。外卖场景每一笔金额都和钱相关,一旦对不上账,开发就变成了背锅侠。
正确的做法是:数据库金额字段用decimal,Java实体里用BigDecimal,传输给前端时统一转成字符串。尤其是做除法计算客单价时,千万不要用double先除再转BigDecimal,正确写法是:
BigDecimal avg = amount.divide(count, 2, RoundingMode.HALF_UP);这里的scale=2表示保留两位小数,roundingMode用四舍五入。如果不写,BigDecimal除法在除不尽时会直接抛ArithmeticException,这个坑我在第一次上手时就踩了。
5.2 统计结果差了八小时,问题出在时区
换了部署环境之后,报表突然发现凌晨的数据被算到了前一天,排查到最后往往是时区问题。JDBC连接串里没有配置serverTimezone,或者配置的时区和服务器所在时区不一致,会导致date(create_time)的分组边界和预期相差8小时。
解决方式是在数据库连接串上强制指定:
jdbc:mysql://localhost:3306/sky_take_out?serverTimezone=Asia/Shanghai同时在定时任务里明确使用LocalDate而不是Date,避免JVM默认时区带来的歧义。如果排查时发现“只有这一天的数据不对”,先去看看是不是跨了零点,别第一时间怀疑SQL写错了。
5.3 定时任务偶发重复执行,统计被翻倍
Spring的@Scheduled默认是单线程执行的,正常情况下不会重复。但项目如果部署了多个实例,或者任务执行时间超过了下一个触发周期,就可能出现重复执行。我在day11里见过最坑的情况是:凌晨任务跑得慢,到了凌晨1点还没结束,结果1点整又触发了一次,同一天的数据被算了两次。
应对方法有两个:第一,在任务方法上加分布式锁,保证同一时刻只有一个实例在跑统计;第二,在任务内部先判断目标日期是否已有结果,已有则先删除再重算,实现幂等。第二种方法成本低,适合单体项目;如果已经上了微服务架构,建议把分布式锁加上。
5.4 Excel下载后文件名乱码或打不开
导出功能上线后,测试反馈“文件名全是乱码”“用WPS打开正常,用Excel打开提示文件损坏”。前者是响应头中文名没有URLEncoder导致,后者通常是Content-Type和文件扩展名不匹配,或者生成的Excel文件根本不是Excel格式,而是错误堆栈信息。
排查这类问题最快的办法是:用Postman直接请求导出接口,把返回内容保存成.xlsx文件,然后用文本编辑器打开看文件头。真正的xlsx文件开头是PK字样,如果看到的是<html>或者一堆异常堆栈,说明接口在写流之前已经报错了。找到了这个根因,再往前翻日志就能很快定位。
5.5 菜品改名导致榜单出现两个同样的菜
这个坑我在第11天下午才真正意识到。运营在后台把“鱼香肉丝”改成了“鱼香肉丝(大份)”,结果销量排行榜里出现了两条数据:一条是历史数据里的旧名字,一条是新名字。分组维度如果用dish_name,这个问题无解。
所以最终我调整了SQL,只按dish_id分组,再做一次子查询把最新的菜品名称带出来。菜品名称只是一个展示字段,不应该参与分组条件。如果项目里没有独立的菜品表,或者菜品表已经被物理删除,还可以考虑在订单明细表里冗余菜品名称的同时,额外冗余一个菜品编码,把这个编码作为分组的稳定键。
最后说句实在话
写统计报表最容易让新手崩溃的一点是:明明数据都在,接口也能通,但报表数据就是和线下Excel对不上。我在day11里反复折腾,最后发现多数问题都不是SQL写错了,而是口径对不上、时区没统一、缓存没清干净。把口径在动手前用中文写清楚,把时间边界统一成“左闭右开”,把每个数字的来源都能讲明白,后面剩下的就只是编码工作量了。
如果你也在做苍穹外卖第11天之后的内容,建议先别急着堆功能,找个下午把已经写完的统计逻辑全量打印一遍,对着一周的假数据逐项核。这个动作看起来慢,其实是在给后面所有管理侧功能打地基。地基稳了,后面加工作台、加数据大屏、加导出历史,都只是扩展而已。