上个月加了个周六的班,就为一个接口。
运营后台的订单统计页,P99耗时稳定在2.8秒,高峰期冲到4秒开外。运营点个"今日GMV"要转圈半天,老板看数据大屏卡了一次,直接在群里@技术,问是不是服务器没续费。
讲真,这种活最不讨喜。业务跑得好好的没人记得你,卡了全是你的锅。
但捋下来发现问题比想象中有意思——不是简单"加个索引"就能搞定,而是从数据库选型、缓存策略到对账补偿的一整套方案。最终接口从2.8秒干到30毫秒,今天把整个过程拆给你看,下次遇到类似场景直接抄作业。
先说结论:不是SQL写得烂,是思路从根上错了
还原一下现场。那个接口是典型的运营统计需求:按时间范围、渠道、状态、商品分类等维度,统计订单数、GMV、客单价、退款率,前端还带分页明细。
原来的写法最朴素——前端传参,Service拼SQL,MySQL执行count、sum、group by,MyBatis映射成VO返回。逻辑看着没毛病,代码也干净。
问题出在数据量。订单主表500多万行,每天还在以几万条增长。运营最爱干什么?选最近30天,不选状态,按分类group by,再加个分页导出。一条SQL扫几十万行,在临时表里排序分组,磁盘临时表都给你跑出来,不慢才怪。
第一反应也是"加索引"。
冷静下来想,这治标不治本。统计型查询和业务型查询不一样。业务查询是"找某条具体数据",索引能精准命中;统计查询是"算一堆数据的汇总",就算建了覆盖索引,InnoDB的count(*)依然要把匹配到的每一行扫一遍逐行计数,几十万行就是几十万次IO。多维度任意组合的情况,索引建少了不顶用,建多了写入维护成本爆炸。
本质问题是:你在用MySQL干它不擅长的活——实时大批量聚合计算。
想明白这点,方案就清晰了:把"查询时实时算"改成"写入时同步算",也就是预聚合。
方案选型:为什么最终选了MySQL+Redis
动手之前先列了几种思路,逐个对比。
SQL优化+索引:短期有效,长期无解。组合维度一多索引根本覆盖不过来,count(*)本身就是InnoDB弱项。放弃。
离线T+1跑批:每天凌晨汇总前一天数据到统计表,但运营要看"今日实时GMV",T+1满足不了实时性。放弃。
ClickHouse/StarRocks这类OLAP引擎:性能确实猛,但为了一个后台统计页面就上新中间件,运维成本和数据同步复杂度都上去了,我们团队小,扛不起。搁置。
Redis预聚合:订单创建、支付、取消、退款时,顺手把对应维度的计数器在Redis里加一。查询端直接读计数器,毫秒级返回。缺点是Redis挂了数据会丢,需要补偿。
最终选的是Redis预聚合+MySQL兜底的组合架构:
- MySQL作为唯一数据源,订单可靠落库,任何时候都能重建Redis里的统计
- Redis作为加速层,所有实时统计走预聚合计数器,查询只读Redis
- 每天凌晨定时全量重建,用MySQL重算指标刷新Redis
- 对账任务周期对比MySQL和Redis值,偏差过大自动重建
"实时增量为主,全量重建兜底"。既拿到Redis的毫秒级响应,又不至于Redis一挂统计就瘫痪。
Key设计:预聚合成也Key败也Key
做Redis预聚合,Key设计是灵魂。随手写死一个字符串,后期维度一加你就崩。
我用的规则:stat:order:{维度标识}:{维度值}:{指标名}:{时间粒度}:{时间值}。
举个例子,"2026年10月18日app渠道已支付订单"的Key:
stat:order:channel:app:status:paid:cnt:day:20261018\stat:order:channel:app:status:paid:gmv:day:20261018
为什么把指标名(cnt/gmv/refund_cnt)也拼进去?因为不同指标数据类型不一样——计数用INCR,金额用INCRBYFLOAT。拆成独立Key,写的时候互不影响,读的时候一次mget就行。
时间粒度做了三层:day、hour、minute。天报表按day,实时大屏看minute。小时和天粒度靠定时任务从分钟粒度roll up。
一个必踩的坑:维度枚举一定要预先收敛,不能让用户随便传值当Key后缀。恶意请求传个随机字符串当channel,Redis瞬间多出几十万个无用Key,内存直接爆。我在代码里做了白名单校验,不在枚举里的维度直接拒绝。
核心实现:双写没你想的简单
关键动作就是在订单状态变更节点——创建、支付、取消、退款——同步更新Redis计数器。
听着就是几个INCR?没那么简单。
第一个坑:计数一定要幂等。 支付回调可能重复投递,MQ也可能重复消费。每次收到"已支付"都INCR,重复消费一次就多算一笔。我基于订单ID做去重:已处理过的订单ID存入Redis SET,处理前先判断。SET只保留最近7天ID,内存可控。
第二个坑:增减要成对。 订单从"待支付"变"已支付",要给paid_cnt加一、unpaid_cnt减一。最开始只加不减,待支付数量一直涨,数据一塌糊涂。后来抽象通用方法,传入旧状态和新状态自动算加减,业务代码只管调。
第三个坑:浮点数精度。 GMV用INCRBYFLOAT累加,多次累加后可能出现19.990000000000002这种尾差。读取时统一用BigDecimal做ROUND_HALF_UP保留两位小数即可。
核心增量逻辑(伪代码):
public void onOrderStatusChange(OrderEvent event) {
\// 1. 幂等去重\String dedupKey = "stat:order:dedup:" + event.getOrderId();\Boolean isNew = redisTemplate.opsForValue()\.setIfAbsent(dedupKey, "1", 7, TimeUnit.DAYS);\if (Boolean.FALSE.equals(isNew)) return;\
\// 2. 旧状态减一,新状态加一\String day = DateUtil.format(event.getOccurTime(), "yyyyMMdd");\updateCounter(event, event.getOldStatus(), -1, day);\updateCounter(event, event.getNewStatus(), 1, day);\}\
\private void updateCounter(OrderEvent e, int status, int delta, String day) {
\String cntKey = String.format(\"stat:order:channel:%s:status:%d:cnt:day:%s",\e.getChannel(), status, day);\redisTemplate.opsForValue().increment(cntKey, delta);\if (status == OrderStatus.PAID.getCode()) {
\String gmvKey = String.format(\"stat:order:channel:%s:gmv:day:%s", e.getChannel(), day);\redisTemplate.opsForValue()\.increment(gmvKey, delta * e.getAmount().doubleValue());\}\}
代码不复杂,关键是所有状态流转入口都要调到,漏一个数据就歪。
深分页顺手也治了
原接口除了统计慢,分页明细也慢。LIMIT 100000, 20 这种写法,MySQL先扫10万行扔掉再取20行,越往后越慢。运营导出数据翻到几千页是常事。
我用游标分页替换offset分页。前端不传pageNo,传上一页最后一条记录的id,WHERE改成:
WHERE id < #{lastId} ORDER BY id DESC LIMIT 20
每次走主键索引,从lastId往后扫20行就完事,翻到第100页和第1页速度一样。缺点是不能跳转到任意页,但运营后台"上一页/下一页"完全够用,这个取舍值得。
兜底:Redis不能成单点
缓存方案最怕什么?Redis挂了,或者某个计数器bug算错了你没发现,运营拿着错数据开周会,那就是生产事故。
做了三层兜底:
本地Caffeine二级缓存:每个统计Key在应用本地缓存10秒。Redis真挂了,10秒内的请求还能顶着,给你留熔断反应时间。
全量重建任务:每天凌晨2点跑任务,用MySQL把前一天所有维度的统计值重算一遍覆盖Redis。相当于每天对一次账,把MQ丢失、服务重启、主从切换等偏差修正掉。
实时对账监控:每15分钟随机抽几个维度,用MySQL实时count和Redis对比。偏差超1%就告警并自动重建该维度。这样就算漏了双写,15分钟内就能发现。
上线后对账跑了一周,最大偏差0.3%,原因是个边界case:订单23:59:59创建、00:00:03支付跨天了。后来把时间维度按业务发生时间取、而非事件处理时间取,就修好了。
最终效果
上线一周后的数据:
- P99耗时:2.8秒 → 32毫秒,提升约87倍
- 高峰期P99:4.2秒 → 48毫秒
- MySQL统计类慢SQL:每天几百条 → 清零
- Redis预聚合Key总内存:不到20MB
- 运营投诉:零。还有运营特意跑过来问"是不是换服务器了,怎么突然这么快"
最后几个提醒
预聚合不是银弹。 维度组合爆炸时(几十个维度任意组合),Key数量会呈笛卡尔积增长,Redis内存可能扛不住。这种场景要么上OLAP,要么只预聚合TOP N高频维度组合,剩下的回源。我这次维度不多(渠道5个、状态6个、分类10个出头),组合数可控才敢这么干。
别在事务里写Redis。 最开始我把Redis更新放在MySQL事务提交前,结果事务回滚了,Redis已经加一,数据对不上。正确做法是在事务commit成功后的回调里发MQ,由消费者异步更新Redis。
计数器用String不用Hash。 一开始图省事想把同一天所有维度塞进一个Hash,HINCRBY一把搞定。后来发现Hash在field多时会触发rehash,单次操作耗时陡增,也没法单独给某个field设过期时间。拆成独立String Key,性能稳,过期也好控制。
过期时间要设对。 天粒度Key设90天过期,够运营查季度数据;分钟粒度只设3天过期,不然内存越积越多。别图省事全设永久,跑半年Redis就满了。
写在最后
这次优化最大的感受:性能问题到了一定阶段,拼的已经不是SQL调优技巧了,而是你愿不愿意换个思路。
遇到慢查询,很多人第一反应是EXPLAIN、加索引、改SQL。这些都没错,但如果数据量和查询模式本身超出了MySQL的舒适区,在SQL层面再怎么抠,收益也是线性的。换一个更适合的组件或组合,收益是数量级的。
但也别为了炫技上一堆花里胡哨的中间件。上ClickHouse、上Flink做实时数仓当然也行,那是杀鸡用牛刀。Redis+MySQL这套组合,两个人周改完,线上跑了两个月稳如老狗,这就够了。
技术选型从来不是选最牛的,是选最合适的。
你们线上有没有遇到过那种"怎么调都调不快"的接口?最后用什么方案解决的?评论区聊聊,我攒着下次写。
小坏说Java一个只说真话的程序员博主 2026年10月19日