前两天晚上,后台收到一条很长的留言。
一个叫阿凯的读者说,他去大厂某部门面三面,被一道题追着问了十几分钟。回来他把经过一字一句记下来发给我,末尾问了一句:
Fox,我到底错在哪一步?我 SQL 明明写对了。
题目是:MySQL 里有 5000 万行访问日志,你怎么统计出 Top 10 热点商品?
我第一遍看完,觉得他答得没什么毛病——SQL 写得挺标准。看到第二遍才反应过来,问题恰恰出在这个"标准"上。
下面这四轮,基本是他复盘里的原话。面试官怎么问、他怎么答,我尽量没动,只在需要补背景的地方加了几句旁白。
他张口就是那条最熟悉的:
SELECT item_id, COUNT(*) AS c
FROM access_log
GROUP BY item_id
ORDER BY c DESC
LIMIT10;
面试官看了一眼,没评价对错,抬头问:你要统计哪个时间段的?
他愣了一下,说全量。
"全量、最近一小时、最近七天,是三个完全不同的题。"
Fox:这句话我给他在复盘里标了红。它把一道 SQL 题直接抬成了架构题,后面三轮全是从这里长出来的。
三个时间口径,对应三种完全不同的做法:
- 全量历史一次算完就不变了,算一次存下来就行,连凌晨跑批都嫌浪费;
- 最近一小时
- 最近七天可以拆成七个"天"的预聚合结果再合并,压根不用碰明细。
然后是第二个问题:要多实时?T+1 报表、分钟级、还是秒级?
第三个:计数必须精确吗?差几个要不要紧?
三个问题问完,路线自己就浮出来了。而他当时满脑子只有一件事——把那条 SQL 写对。
第二轮:「那这条 SQL 在 MySQL 里到底干了什么」
他被问住了。
当晚他把经过发给我。我没直接回答,只让他干一件事:搭一台 8C16G 的实例,塞 5000 万行数据,把 EXPLAIN 打出来看。
半小时后截图回来了,Extra 那一栏两个字:Using temporary、Using filesort。
翻译成人话,MySQL 干的事是这样的:
- 按
item_id 聚合,假设有 200 万个不同商品,就得维护 200 万个计数器;
这是那晚他收获最大的一句。
Fox:这也是绝大多数人的误解。以为 LIMIT 10 会让 MySQL 聪明一点——扫到差不多就停。不会的。它不知道谁会出现在前十,除非把所有人排完序。
那加索引呢?他补着加了 idx_item(item_id)。
临时表确实有可能消失(EXPLAIN 里变成 Using index for group-by),但 5000 万行的索引扫描一行也少不了——COUNT(*) 要的是精确值,就得数满每一个条目。耗时从"几十秒"变成"十几秒",量级没动。
顺手记三个监控指标,后来真用上了:
第三轮:「不要 Flink,就一台 MySQL 呢」
他说可以预聚合——凌晨把结果算好,白天只查结果表。
面试官点头,然后问:如果你们公司没大数据栈呢?就这一台 MySQL。
这轮他答得最差,因为把希望全押在 Flink 上了。
后来我们俩一起补了三条能落地的:
一、把结果提前算成一张十行的表。
CREATE TABLE hot_top10_snapshot (
dt DATENOT NULL,
rank_no TINYINTNOT NULL,
item_id BIGINTNOT NULL,
cnt BIGINTNOT NULL,
PRIMARY KEY (dt, rank_no)
);
-- 凌晨跑批写进去,白天的在线接口就这一句
SELECT item_id, cnt FROM hot_top10_snapshot
WHERE dt = CURDATE() ORDER BY rank_no;
主键查询,10 行,毫秒级。
阿凯他们组后来就是这么改的。他们运营后台原来早上九点一打开就卡半分钟,运营姑娘已经习惯了——先点开,去接杯水,回来正好。改完之后没上 Kafka,没上 Flink,一张快照表解决问题。老板要的从来不是"实时",是"打开不卡"。
二、分区 + 分段并行。
按天分区,查"今天"只扫一个分区;再按主键切成 16 段并发跑,墙钟时间压到约 1/16,每段的临时表也小到能留在内存里。
SELECT item_id, COUNT(*) AS c
FROM access_log
WHERE id >= ? AND id < ?
GROUP BY item_id
ORDER BY c DESC
LIMIT10;
但这里有个坑,阿凯真踩过。
以为 16 段各取 Top 10 再归并就是全局 Top 10,不是的。构造个反例就明白:商品 X 在每一段里都排第 11,每一段的第 1 名又都是各不相同的"本地爆款"——归并时 X 压根进不了候选集,可它加起来可能是全局第一。
所以:要快,就接受归并是近似的;要精确,就得让每段吐出完整计数落中间表,再统一排序。这不是 bug,是取舍。
三、采样。
MySQL 没有 TABLESAMPLE(那是 PG 和 Oracle 的),只能自己抽:
-- 按主键等距抽样 1%,再放大
SELECT item_id, COUNT(*) * 100AS c
FROM access_log
WHERE id % 100 = 0
GROUP BY item_id
ORDER BY c DESC
LIMIT10;
快一百倍,长尾不准。看趋势够用,结算口径不行。
第四轮:「如果我说,Top 10 根本不需要精确呢」
这一轮把他问傻了。
他从来没想过这个问题——统计不就是要准确吗?
面试官提醒他:运营看的是"哪十款卖得好",不是"第三名到底 9213 件还是 9207 件"。这是个头部问题。
头部问题有个经典解法,叫 Space-Saving(Metwally, 2005)。思路朴素得不像算法:
维护一张容量固定为 K 的表(K 取 1000,远大于 10)。表满了,就把当前计数最小的那个踢掉,新来的顶替它,并继承被踢者的计数。
三条规则:
- 不在表里、表满了 → 踢掉最小的 y,插入
(item, count[y] + 1),误差上界记为 count[y]
为什么这样是对的?因为真正的高频项每次都会 +1,它根本不会长期待在最小的位置上,踢不到它。而被踢的那些,本来就是长尾。
Java 的核心逻辑就这么点:
public classSpaceSavingTopK {
private final int capacity;
private finalMap<Long, Node> table = newHashMap<>();
private finalPriorityQueue<Node> minHeap =
newPriorityQueue<>(Comparator.comparingLong
(n -> n.count));
static classNode {
final long itemId;
long count;
long error;
Node(long itemId) { this.itemId = itemId; }
}
public voidoffer(long itemId) {
Node node = table.get(itemId);
if (node != null) {
node.count++;
minHeap.remove(node);
minHeap.offer(node);
return;
}
if (table.size() < capacity) {
node = newNode(itemId);
node.count = 1;
table.put(itemId, node);
minHeap.offer(node);
return;
}
// 表满:踢掉计数最小的,新 item 继承它的计数
Node victim = minHeap.poll();
table.remove(victim.itemId);
node = newNode(itemId);
node.count = victim.count + 1;
node.error = victim.count; // 过估计上界
table.put(itemId, node);
minHeap.offer(node);
}
publicList<Node> topK(int k) {
return table.values().stream()
.sorted((a, b) -> Long.compare(b.count, a.count))
.limit(k)
.collect(Collectors.toList());
}
}
1000 个槽位,内存几十 KB,5000 万行单机几十秒跑完,全程不落盘、不分布式。
代价也说清楚:它只会高估,不会低估,长尾完全不准。但 Top 10 用不上长尾。
顺带一句:minHeap.remove(node) 是 O(K)。真要跑生产,可以换成原论文的 Stream-Summary(桶 + 双向链表),把单次更新摊还到 O(1)。面试提一嘴就够了。
阿凯三面过了。入职半年后他跟我说,那套榜单后来还是上了实时链路——老板看了实时榜就回不去了。踩的坑按痛感排序:
- 数据倾斜。某个爆款商品,量级是普通商品的几万倍,
keyBy(itemId) 之后那一个 subtask 直接被打满。解法是二级聚合——先给 key 加随机盐打散成 16 份,聚合完再去盐合并。 - ZSET 膨胀。只
ZINCRBY 不裁剪,一个月后一个 key 里几百万成员。解法:每天一个新 key、整 key 设 TTL、定期 ZREMRANGEBYRANK key 0 -1001 只留前一千。 - 历史霸榜。累计计数让上个月的大爆款永远挂在第一。解法是给分数加衰减:
score = 旧分 × 0.95 + 本次增量。
# 累加
ZINCRBY hot:20261009 1 10086
# 取 Top 10,O(log N + 10)
ZREVRANGE hot:20261009 0 9 WITHSCORES
# 只留前 1000,防止成员膨胀
ZREMRANGEBYRANK hot:20261009 0 -1001
取 Top 10 这条命令基本不耗时,瓶颈全在 Flink 侧。
那场面试他过了。但两千多字的复盘里,他反复回味的还是第一轮那句话。
这两年我收到的SQL面经复盘不下百份,几乎都是同一个开头——一上来就优化那条 SQL:加索引、改 LIMIT、调 sort_buffer。全是在给一个错误的方向打补丁。
真正的问题从来不是"这条 SQL 怎么更快",是"这个榜单到底要给谁看"。
想清楚这句,一张快照表就够了,Flink 都不用上。

1、一个可执行文件里到底装了什么?图解 ELF 文件
2、vLLM 是什么?为什么能让大模型推理性能更好?
3、这 6 个 GitHub 项目是刚开源,但是 Star 攀升很快的几个。
4、Linux内核编译要进入10秒时代了
5、明明类型完全一致,为什么 dynamic_cast 还是返回了 nullptr?
