百万数据统计在 MySQL 里能慢到什么程度?这次我是真真切切体会到了。当时我手头维护的是一个电商后台系统,里面有一张订单明细表,负责给运营看板提供数据。最开始表里也就三四十万行,统计接口基本秒开。可等量级爬到两百多万行之后,运营反馈那个“经营看板”页面打开一次要半分钟左右,页面里的数字迟迟不加载,第一批点进去的同事甚至以为系统卡死了。
那段时间我每天都要收到好几条“统计怎么又超时”的消息。刚开始我也以为是网络问题,后来打开慢查询日志才确认,锅全在几条统计 SQL 上。当时用 MySQL + Redis 做百万数据统计优化的完整过程,包括最初的故障定位、方案选型、核心改造代码,以及那些不实际踩一遍根本想不到的坑,我都记在这篇里了。如果你正在为某个统计接口从几秒变成几十秒头疼,或者你已经在考虑引入大数据组件但不知道值不值得,这篇内容应该能给你一个比较有参考价值的解法。
1. 事故现场:一个统计接口从 1 秒变成 27 秒
1.1 业务场景与 SQL 现场
这个后台看板需要统计的指标并不复杂:
- 今日订单总数、今日销售额
- 支付成功、待付款、退款中、已关闭各状态订单数
- 今日客单价、昨日同环比
- 近 7 天订单量趋势
全部来自一张order_info表。表结构也不是什么特别夸张的大宽表,核心字段大概长这样:
CREATE TABLE `order_info` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单号', `user_id` bigint NOT NULL COMMENT '用户ID', `status` tinyint NOT NULL COMMENT '订单状态:1待付款 2已支付 3已发货 4已完成 5退款中 6已关闭', `channel` tinyint NOT NULL COMMENT '下单渠道 1小程序 2APP 3线下', `amount` decimal(12,2) NOT NULL COMMENT '订单金额', `pay_time` datetime DEFAULT NULL COMMENT '支付时间', `create_time` datetime NOT NULL COMMENT '下单时间', PRIMARY KEY (`id`), KEY `idx_status` (`status`), KEY `idx_create_time` (`create_time`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB;原本的统计实现非常“直接”,这几个指标全是实时查表算出来的。看板加载时主接口会并行执行好几条类似的 SQL:
SELECT COUNT(*) AS order_count, COALESCE(SUM(amount), 0) AS total_amount FROM order_info WHERE create_time >= '2025-03-01 00:00:00' AND create_time < '2025-03-02 00:00:00'; SELECT status, COUNT(*) AS cnt FROM order_info WHERE create_time >= '2025-03-01 00:00:00' AND create_time < '2025-03-02 00:00:00' GROUP BY status;单独看任何一条 SQL 都不算重,问题是它们全挤在一个页面上。表量级到了 200 万行以后,配合上运营筛选渠道、时间范围等条件,单条 SQL 就开始出现 4~8 秒的耗时,多条并行再加上页面轮询,接口直接把慢查询日志刷屏了。
1.2 索引为什么救不了统计场景
刚开始团队的第一反应也是加索引。给create_time、status、channel都建了单列索引,后来又试过联合索引(create_time, status, channel)。效果确实有,但非常有限。单天数据量在十几万行的时候,即使走create_time索引,MySQL 也需要把这些行全部读出来再聚合。因为统计查询本质上是把某个时间段内所有满足条件的行“扫”一遍,而不是精确找某几行。
这里有个容易忽略的问题:普通索引只能帮你缩小扫描范围,没法帮你省略计算。你要的是 SUM、COUNT、GROUP BY 的结果,数据库必须访问所有命中的行。除非你要查的是一个有前缀匹配能力的单条记录,否则索引只是让扫描从 200 万行变成 20 万行,计算成本依然存在。
EXPLAIN 的结果也证明了这一点:核心查询走的是idx_create_time索引,type 为 range,rows 估算 20 万左右,Extra 里出现Using temporary; Using filesort。20 万行不算多,但如果每天看板要被几十个人开几十次,再加上好几次分组统计并行执行,数据库 CPU 很快就飙上去了。
那段排查经历给我的最大启发是:在“频繁多维聚合”这个场景里,与其不断优化 MySQL 的“即时计算”能力,不如想办法把计算提前到数据写入那一刻。看板需要的永远是一个结果数字,而不是中间那 20 万行明细。
2. 为什么没有直接换库:三个方案对比与取舍
2.1 常规解法盘点
当时团队内部其实提过好几个方向,除了在 MySQL 上做 SQL 优化,还有重一点的方案。我简单列一下当时对比过的三条路。
第一种:继续在 MySQL 里做文章。比如增加汇总表、分区表、物化视图。MySQL 8 的物化视图不算内建,只能靠定时任务手动刷。分区表对这类跨天统计帮助也存在局限,如果今天不分区,按create_time查历史数据,依然要扫全表。最后我们判断这条路只适合小步优化,解决不了根子上的“每次现算”问题。
第二种:引入 OLAP 引擎。例如把订单明细实时同步到分析型数据库,看板查询全部走分析引擎。这类引擎在亿级数据量下也能秒出结果,能力确实强。但代价是至少要新增一套组件,还要处理明细数据同步链路,原本 MySQL 主从、备份、监控那套运维体系全要往外延伸一圈。我们当时数据量才 200 万,专职负责这个系统的人手也有限,上重组件有点杀鸡用牛刀。
第三种:就是这次实施的 MySQL + Redis 组合优化方案。核心思路是:把统计结果“事前算好”,然后让 Redis 扛实时读,MySQL 只负责存明细和做离线兜底。这个方案不引入任何新存储,能复用现有 Redis 集群,研发成本相对可控。
2.2 三个方案的横向对比
| 方案 | 开发量 | 运维成本 | 实时性 | 数据量适用段 | 主要风险 |
|---|---|---|---|---|---|
| SQL 优化+索引 | 小 | 无新增 | 秒级 | 百万边缘 | 无法根治现算开销 |
| 引入 OLAP 引擎 | 中 | 高 | 秒级 | 千万到亿级 | 组件运维与同步延迟 |
| MySQL+Redis 预计算 | 中 | 低 | 秒级 | 百万到千万 | Redis 数据一致性 |
对比完就很清晰了:200 万行这个量级,既没有到必须用分析型数据库的程度,也不能继续纵容 MySQL 反复全表聚合。用 Redis 在业务写入时同步累加计数,再用一张预聚合表承接跨天趋势,属于“刚刚好”的复杂度。
2.3 别忽略一个前提:统计口径固定
这套方案能成立的前提是,看板上的指标要相对固定,不能是那种“用户随意选字段、自由组合维度”的分析页面。一旦维度组合爆炸,Redis 里的 key 数量也会爆炸,预聚合表更没法提前算。我们当时的看板恰好是固定指标,只是时间范围可选,就很适合这么干。
所以做任何技术选型之前,先把业务场景框清楚。这看起来像废话,但真到了项目里,很多人一上来就谈组件、谈数据量,反而把“为什么需要统计、统计项会不会变”给放一边了。结果往往是把一个轻量业务搬上重型架构,后期维护成本远比想象中高。
3. 核心改造:Redis 计数器、预聚合表与查询路由三层结构
3.1 先按指标特性分类
把所有统计指标拆成三类,决定它们各自的存储位置:
- 实时累加类:今日订单数、今日销售额、支付成功数、退款数。这类指标本质上是一个不断增长的计数器,最适合用 Redis 的 INCR、HINCRBY 处理。
- 分组分布类:各状态订单占比、各渠道销量。需要按维度分组,Redis 的 Hash 可以做到 O(1) 更新单个分组,也可以按分数排序用 ZSet。
- 跨天趋势类:近 7 天订单趋势、近 30 天销售额曲线。Redis 里不适合长期存这么多天数据,放到 MySQL 预聚合表里更合适,定时任务每小时汇总一次即可。
这个分类非常关键。分类错了后面代码全是坑。比如你把趋势类指标也塞进 Redis,不设过期时间的话,内存会一路涨到让你怀疑人生。
3.2 Redis 键设计与写入代码
键的命名我统一设计成:stat:{业务}:{指标}:{yyyyMMdd}。例如:
stat:order:total:20250301 stat:order:status:20250301 stat:order:channel:20250301 stat:order:amount:20250301其中status和channel用 Hash 结构,field 是具体状态值/渠道值,value 是订单数。amount用 String 累加销售额。核心更新代码大概是这样的:
// 订单创建成功时,同步累加当日统计 String day = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); String totalKey = "stat:order:total:" + day; String statusKey = "stat:order:status:" + day; String channelKey = "stat:order:channel:" + day; String amountKey = "stat:order:amount:" + day; // 同一批 Redis 操作在事务/管道里执行,减少网络开销 stringRedisTemplate.executePipelined((RedisCallback<Object>) connection -> { connection.incr(totalKey.getBytes()); connection.hIncrBy(statusKey.getBytes(), String.valueOf(order.getStatus()).getBytes(), 1); connection.hIncrBy(channelKey.getBytes(), String.valueOf(order.getChannel()).getBytes(), 1); connection.incrBy(amountKey.getBytes(), order.getAmount().movePointRight(2).longValue()); return null; });这里有两个容易忽略的细节。第一,金额我转成了整数再累加,避免浮点误差;第二,一次订单创建涉及多个 key 更新,我放在同一个 pipeline 里执行,把多次网络交互合并成一次,实际在高并发下对 Redis 的压力会小很多。
客单价这种派生指标不需要单独存,查询时用当日销售额 / 当日订单数算出来即可。因为销售额和订单数都在 Redis 里,两个读操作一起取出来,一次接口请求也就多几十微秒。
3.3 MySQL 预聚合表:承接历史趋势
为了近 30 天趋势和跨天对比,我加了一张简单的汇总表:
CREATE TABLE `order_stat_hourly` ( `stat_date` date NOT NULL COMMENT '统计日期', `stat_hour` tinyint NOT NULL COMMENT '统计小时 0-23', `status` tinyint NOT NULL COMMENT '订单状态', `order_count` int NOT NULL DEFAULT 0 COMMENT '订单数量', `total_amount` decimal(14,2) NOT NULL DEFAULT 0 COMMENT '订单金额', PRIMARY KEY (`stat_date`, `stat_hour`, `status`) ) ENGINE=InnoDB COMMENT='订单小时汇总表';这张表由一个定时任务维护。任务每 10 分钟跑一次,拉取最近一个小时内新增/变更的订单,按stat_date, stat_hour, status分组后做增量累加。注意我没有用“每天凌晨全量重刷”的方式,因为当天数据还在变化,等到凌晨再刷就来不及了。
增量同步的 SQL 大致逻辑是:
INSERT INTO order_stat_hourly (stat_date, stat_hour, status, order_count, total_amount) SELECT DATE(create_time), HOUR(create_time), status, COUNT(*), SUM(amount) FROM order_info WHERE create_time >= ? AND create_time < ? GROUP BY DATE(create_time), HOUR(create_time), status ON DUPLICATE KEY UPDATE order_count = VALUES(order_count), total_amount = VALUES(total_amount);这里有个教训:不能直接对整个小时段反复跑同一段 SQL。如果同一小时的数据因为补单、退款等原因发生变更,重复执行会把计数加重复。我当时给汇总任务加了一个“按订单update_time >= 上次执行时间”的增量过滤条件,保证一条订单在一个小时窗口内只被统计一次。具体实现上还会配合一个本地任务执行状态表,避免定时任务重复触发。
补充一句:这张小时表默认只保留最近 90 天。更早的数据如果业务上还需要,再按天汇总一层归档,完全没必要在明细表上做历史全量统计。
3.4 查询路由:三层顺序怎么定
改造后的查询逻辑很直接,接口入口处做了一个路由判断:
- 查“今日/今日实时”类指标,直接读 Redis,不存在才回源;
- 查“近 7 天趋势、昨日对比”类指标,读 MySQL 预聚合表,一次查出多天小时数据后在应用层做二次聚合;
- 只有兜底场景才查询明细表,且查询结果设置短缓存。
这个顺序的核心理念是:能查结果就不查明细,能用预聚合就不用实时算。Redis 的价值是让当天数据读取变成 O(1) 内存操作;MySQL 预聚合表的价值是让历史趋势查询只面对一张“每天都只有几十行”的小表。
我举个例子你就懂了。改造后查“近 7 天订单量趋势”,对应的 SQL 是这样的:
SELECT stat_date, SUM(order_count) AS total FROM order_stat_hourly WHERE stat_date >= ? AND stat_date < ? GROUP BY stat_date;这张表全量撑死也就 90 天 × 24 小时 × 几个状态,几千行而已。哪怕用最笨的全表扫描,耗时都在几毫秒级别。和之前动辄扫描几十万订单明细完全是两个量级。
4. 改造过程中最折磨人的三个坑
4.1 事务边界与 Redis 不一致:定时对账兜底
第一个坑来自架构上绕不开的问题:MySQL 事务和 Redis 操作无法保证原子性。业务代码里如果在一个事务方法中先写订单表,再更新 Redis,一旦 Redis 操作超时或失败,订单已经入库但计数器没加上,当天的统计数就会越差越多。
这个问题在理论上有很多优雅解法,比如事务消息、本地消息表、binlog 订阅后异步消费。但当时线上系统已经比较老,改造面太大。我退而求其次,做了两层补偿:
- 业务上将 Redis 更新放在事务提交之后执行,这样至少不会把“没用的事务”也算进计数;
- 新增一个每天凌晨执行的“统计对账任务”,用预聚合表里前一天的结果和 Redis 中的统计值做差值校验,超过阈值就自动重建对应时间的统计值。
这个方案不完美,胜在简单可控。对账任务会打印差异明细,运营那边也接受“偶尔延迟修正,但不会一直错下去”的口径。如果你所在业务对准确性要求非常苛刻,我还是建议老老实实上事务消息或消费 binlog,别学我这个偷懒方案。
4.2 回填风暴:Redis 重启之后别让 MySQL 裸奔
第二个坑是在压测阶段爆出来的。Redis 集群做过一次主从切换,重启之后内存里空空如也,看板页面刚好被运营开着,一瞬间所有请求全部打到 MySQL 上,把主库 CPU 直接跑满。
这个现象就是经典的缓存穿透。我们原本给每个统计 key 设置了当日过期时间,比如expireAt = 当天 23:59:59,但根本没有为“Redis 重启后数据怎么回来”设计预案。
后来加了两个机制。第一是启动预热:应用启动时按“热 key 优先”原则,把昨日和今日的统计 key 用异步任务逐批回填,逐批的意思是一批只回填 100 个 key,不一次性全量干。第二是查询互斥:读 Redis 发现没有值时,只让一个线程负责回源查 MySQL,其余请求短暂等待或返回上次成功值,防止同一时间内大量线程同时回源。
另外,为了不让 key 在同一时刻集体过期造成集中穿透,过期时间我也加上了随机扰动。比如基础 TTL 是 1 小时,实际 TTL 是基础时间 + random(0, 300)秒。别小看这五分钟的随机性,它能让回源压力在时间轴上摊平。
4.3 Redis 内存无界增长:按日期滚动删除
第三个坑比较隐蔽,是在上线一周后看 Redis 内存监控时发现的。统计 key 如果只加不过期,短时间内没什么问题,时间长了会变成内存黑洞。比如按天统计的 key 如果永久保留,一年就是 365 组,每组包含多个 Hash field,日积月累非常可观。
我当时用了一个很土但有效的办法:只保留近 3 天的实时统计 key,历史数据一律以 MySQL 预聚合表为准。每天凌晨对账任务结束后,把昨天之前所有stat:*前缀的 key 全部删除。为了避免删除大 key 阻塞 Redis,用的是 SCAN 命令分批遍历删除,而不是 KEYS 全量遍历。
# 伪代码:扫描删除过期统计 key SCAN 0 MATCH stat:*:20250228 COUNT 1000 # 循环分批 DEL另外一个兜底措施是给 Redis 配置了maxmemory-policy allkeys-lru,万一内存不够,最不常用的统计 key 会被优先淘汰,不至于把整个缓存打挂。
对了,删除历史 key 也要注意时机。我一开始放在对账任务的最前面执行,结果刚删完统计 key 又有查询进来触发了回源,把数据库打出一阵小高峰。后来把删除动作放到对账任务完全结束之后再执行,就平稳多了。
5. 最终收益与这套打法的适用边界
5.1 性能数据对比
改造上线一周后,我从监控系统里拉了一组对比数据:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 经营看板首屏加载耗时 | 27 秒 | 180~250 毫秒 |
| 数据库主库 CPU 使用率 | 峰值 92% | 峰值低于 25% |
| Redis 新增内存占用 | 0 | 约 45MB(3 天数据) |
| 日订单量支持上限 | 表越大越卡 | 平稳支撑,无感知 |
这组数据给我最大的触动不是“快了 100 倍”,而是数据库彻底从统计重压下解放出来了。之前慢查询日志里被看板 SQL 霸屏,改造后慢查询日志几乎安静了。运营那边也从“打开页面先转圈”变成了“数字秒出”,体感完全不同。
5.2 这套方案能用多久
结合这次实战,我想给这套 MySQL + Redis 优化打法划个清晰边界。
它适合以下场景:数据量在百万到千万级;统计指标固定、维度不会爆炸;实时性要求高;团队没有专职运维大型分析引擎的人力;Redis 基础设施已经存在。
它不适合以下场景:数据量已经上亿且还在快速增长;允许用户自由选择任意字段做多维聚合;需要关联多张明细大表做复杂计算;对数据准确性要求达到“一分钱都不能差”的金融级别。这四类场景建议直接考虑分析型数据库、数据仓库或专门的预计算引擎。
如果我们这个订单表继续以每年翻倍的速度增长,到千万级时预聚合表方案依然能扛,因为趋势数据已经提前算好了;但如果哪天业务方开始要求“按任意渠道+任意活动+任意城市交叉分析”,这套方案就需要升级了。到那时候,我会优先考虑把明细数据同步进分析型引擎,而 Redis 计数器这套思路可以继续保留在实时指标层。
最后说一点个人体会。这次改造并没有用到什么特别高深的技术,Redis 的 INCR、HINCRBY 恐怕每个用过 Redis 的人都会写,MySQL 的INSERT ... ON DUPLICATE KEY UPDATE也是常见语法。真正让我觉得值得记录的,是整个问题的思考路径:为什么一开始没发现统计 SQL 会变成瓶颈,为什么加索引只能缓解不能根治,为什么最后没有直接上大数据组件而是选了最朴素的三层结构。
我个人最大的心得是:面对百万数据统计,先别急着换引擎,先看看能不能把统计计算前置。很多统计场景其实只需要结果,不需要随时现算明细。把“事中计算”改成“事前累计”,再用 Redis 把热数据顶在最前面,成本低、见效快、后续也好维护。当然,方案是否适用,永远取决于你的业务指标稳定不稳定。如果指标天天变,这套路就走不通了。
希望这篇记录能给正在折腾 MySQL 大表统计的你一点启发。