先抽个结论:在 MySQL 8.0 及以上版本里,用 ROW_NUMBER() OVER (PARTITION BY category ORDER BY RAND()) 做分组随机抽样,是最靠谱、语义最清晰的写法。它会先在每组内把记录顺序打乱,再编号,最后取每组 rn = 1 的那一行——干净利落,逻辑透明。
很多人容易踩的坑是:在最外层加个 ORDER BY RAND()。这其实只是把全表随机排序了一遍,然后分组——结果根本就不是“每组一条”,而是不可控的随机行数。还有人误用 GROUP BY 配合 MIN(id),本质是取最小 ID,跟随机八竿子打不着。

MySQL 8.0+ 用 ROW_NUMBER() + RAND() 实现分组随机抽样
直接上正确写法,然后说几个关键点:
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY category ORDER BY RAND()) AS rn FROM products) t WHERE rn = 1;
RAND()必须放在窗口函数的ORDER BY里,而且不能加其他确定性排序字段。比如写成ORDER BY category, RAND(),因为category是稳定的,同组内多次执行结果会一模一样,等于没随机。- 窗口函数只支持 MySQL 8.0+ 和 PostgreSQL;MariaDB 10.2+ 也支持,但旧版 MySQL(5.7 及之前)不行,得换方案。
- 性能方面:全表扫描 + 每组内部排序,数据量大时会慢。如果只是偶尔跑一跑,或者数据在百万级以下,基本可以接受。
PostgreSQL 用 DISTINCT ON + ORDER BY RANDOM()
PostgreSQL 提供了一个更简洁的语法:DISTINCT ON 按指定列去重,配合 ORDER BY ... RANDOM() 控制保留哪一条。天生适合分组抽样,而且不需要子查询,清爽很多。
SELECT DISTINCT ON (category) *FROM productsORDER BY category, RANDOM();
这里有一个容易踩的坑:DISTINCT ON (category) 要求 ORDER BY 最左字段必须是 category,否则直接报错。另外 RANDOM() 必须出现在 ORDER BY 里,不能只靠 WHERE 过滤。
DISTINCT ON返回的是“每组第一条”,所以ORDER BY category, RANDOM()才能保证每组内随机选一条。- 如果还想返回原表所有字段,
category必须放在ORDER BY开头,后面接RANDOM(),其他字段顺序不影响结果。 - 相比窗口函数,这个写法更轻量,执行计划通常更优,尤其在有
category索引时。
MySQL 5.7 或 SQLite 等不支持窗口函数的场景
只能退回到“关联子查询 + LIMIT 1”模式。但注意:MySQL 5.7 不允许子查询里直接用 LIMIT 配合外部引用,必须包装成派生表;SQLite 则直接支持 LIMIT 写在子查询里。
性能差是最大问题——对主表每行都执行一次子查询,复杂度 O(n²)。数据超 1 万行就会明显卡顿。线上环境慎用,仅限临时查或小表。
- MySQL 5.7 正确写法:子查询要用双层括号,比如
(SELECT * FROM (SELECT ... ORDER BY RAND() LIMIT 1) AS tmp)。 - SQLite 可以直接写
(SELECT * FROM products p2 WHERE p2.category = p1.category ORDER BY RANDOM() LIMIT 1)。 - 所有方案中,这是唯一可能重复返回同一行(虽然概率低),因为
RAND()在子查询里每次独立生成,无法保证组内真正“只选一次”。
-- MySQL 5.7 示例(注意双层括号)SELECT p1.* FROM products p1WHERE p1.id = ( SELECT id FROM ( SELECT id FROM products p2 WHERE p2.category = p1.category ORDER BY RAND() LIMIT 1 ) AS tmp);
为什么不能用 GROUP BY + AGGREGATE(比如 MAX(id)) 模拟随机?
因为 MAX(id)、MIN(name) 这类聚合函数返回的是确定值,跟随机完全不沾边。有人试图用 GROUP_CONCAT(id ORDER BY RAND()) 再 SUBSTRING_INDEX 截取第一个,看似随机,实则隐患巨大:
GROUP_CONCAT有长度限制(默认 1024 字符),超长组会截断,导致取不到真实 ID。- 字符串拼接后取子串,无法保证类型安全;如果 ID 是 UUID 或含逗号,解析直接失败。
- MySQL 5.7 默认不开启
sql_mode=ONLY_FULL_GROUP_BY时,SELECT *配合GROUP BY会返回非确定性行,看起来像随机,其实是引擎随意选的,不可靠也不可移植。
真要兼容老版本,又不能接受性能损耗,优先考虑应用层分组后随机取——查出全部分组数据,用 Python/JS 在内存里按组 shuffle 再取首项,比写 SQL 黑魔法稳当得多。
