社区所有版块导航
Python
python开源   Django   Python   DjangoApp   pycharm  
DATA
docker   Elasticsearch  
aigc
aigc   chatgpt  
WEB开发
linux   MongoDB   Redis   DATABASE   NGINX   其他Web框架   web工具   zookeeper   tornado   NoSql   Bootstrap   js   peewee   Git   bottle   IE   MQ   Jquery  
机器学习
机器学习算法  
Python88.com
反馈   公告   社区推广  
产品
短视频  
印度
印度  
Py学习  »  DATABASE

大厂面试:MySQL 里有 5000 万数据,要统计 Top 10 热点,怎么搞?

IT服务圈儿 • 4 天前 • 44 次点击  

来源丨经授权转自 Fox爱分享

作者丨Fox爱分享

前两天晚上,后台收到一条很长的留言。

一个叫阿凯的读者说,他去大厂某部门面三面,被一道题追着问了十几分钟。回来他把经过一字一句记下来发给我,末尾问了一句:

Fox,我到底错在哪一步?我 SQL 明明写对了。

题目是:MySQL 里有 5000 万行访问日志,你怎么统计出 Top 10 热点商品?

我第一遍看完,觉得他答得没什么毛病——SQL 写得挺标准。看到第二遍才反应过来,问题恰恰出在这个"标准"上。

下面这四轮,基本是他复盘里的原话。面试官怎么问、他怎么答,我尽量没动,只在需要补背景的地方加了几句旁白。

第一轮:「先别写 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 干的事是这样的:

  • 把 5000 万行全部扫一遍;
  • 按 item_id 聚合,假设有 200 万个不同商品,就得维护 200 万个计数器;
  • 内存装不下,落 磁盘临时表;
  • 对这 200 万个中间结果排一次序;
  • 扔掉前 1999990 行,返回 10 行。

LIMIT 10 省的是返回,不是计算

这是那晚他收获最大的一句。

Fox:这也是绝大多数人的误解。以为 LIMIT 10 会让 MySQL 聪明一点——扫到差不多就停。不会的。它不知道谁会出现在前十,除非把所有人排完序。

那加索引呢?他补着加了 idx_item(item_id)。

临时表确实有可能消失(EXPLAIN 里变成 Using index for group-by),但 5000 万行的索引扫描一行也少不了——COUNT(*) 要的是精确值,就得数满每一个条目。耗时从"几十秒"变成"十几秒",量级没动。

顺手记三个监控指标,后来真用上了:

状态变量
它涨了说明什么
Created_tmp_disk_tables
临时表落盘了,速度掉一个数量级
Sort_merge_passes
排序分片太多,反复归并
Handler_read_rnd_next
大量全表扫描

扫 5000 万,排 200 万,返回 10 行

第三轮:「不要 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;

快一百倍,长尾不准。看趋势够用,结算口径不行。

没 Flink 也能做,只是各有各的代价

第四轮:「如果我说,Top 10 根本不需要精确呢」

这一轮把他问傻了。

他从来没想过这个问题——统计不就是要准确吗?

面试官提醒他:运营看的是"哪十款卖得好",不是"第三名到底 9213 件还是 9207 件"。这是个头部问题。

头部问题有个经典解法,叫 Space-Saving(Metwally, 2005)。思路朴素得不像算法:

维护一张容量固定为 K 的表(K 取 1000,远大于 10)。表满了,就把当前计数最小的那个踢掉,新来的顶替它,并继承被踢者的计数。

三条规则:

  • 新 item 已在表里 → 计数 +1
  • 不在表里、表没满 → 插进去,计数 1
  • 不在表里、表满了 → 踢掉最小的 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面经复盘不下百份,几乎都是同一个开头——一上来就优化那条 SQL:加索引、改 LIMIT、调 sort_buffer。全是在给一个错误的方向打补丁。

真正的问题从来不是"这条 SQL 怎么更快",是"这个榜单到底要给谁看"。

想清楚这句,一张快照表就够了,Flink 都不用上。

1、一个可执行文件里到底装了什么?图解 ELF 文件

2、vLLM 是什么?为什么能让大模型推理性能更好?

3、这 6 个 GitHub 项目是刚开源,但是 Star 攀升很快的几个。

4、Linux内核编译要进入10秒时代了

5、明明类型完全一致,为什么 dynamic_cast 还是返回了 nullptr?

Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/202990