SQL 子查询如何统计每个分类的记录数量

不晚
不晚
发布时间:2026-08-23| 189人浏览过
原创

前言

在 SQL 里统计“每个分类有多少条记录”,很多人会先想到子查询,但真正容易出错的地方其实是分组粒度、计数对象和边界数据处理。下面就按错误写法、推荐方案、子查询适用场景和脏数据补救四个层次拆开讲,帮助你判断什么时候该用 LEFT JOIN + GROUP BY,什么时候才需要退回相关子查询。

很多人一看到“按分类统计数量”,就会先往子查询上想,但真正决定结果是否正确的,是有没有把 COUNT() 放到明确的分组逻辑里。本文把几种常见写法拆开说明:哪些写法看似能跑、其实不适合做“每个分类数量”,为什么 LEFT JOIN + GROUP BY 往往是更稳的默认方案,以及遇到 NULL 分类和脏数据时该怎么补齐统计口径。

为什么只写 COUNT() 不够

要拿到“每个分类的记录数”,不能只写一个 COUNT(),还必须告诉数据库“按哪个字段分组”。如果没有 GROUP BY,聚合函数会把整个结果集压成一行,得到的只是总数,而不是每个分类各自的数量。

常见误用有两类。

错误一:只统计总数,没有分类维度

SELECT (SELECT COUNT(*) FROM products) FROM categories

这条语句没有任何分组逻辑,内层子查询只会返回 products 的总行数。外层即使扫描了 categories,也只是重复显示同一个总数,不能回答“每个分类有多少条”。

错误二:把相关子查询当成分组统计的替代品

SELECT name, (SELECT COUNT(*) FROM products WHERE category_id = c.id) FROM categories c

这条写法语法上可以成立,但它不是用子查询“完成分组”,而是让外层每扫到一条分类,内层就重新算一次数量。也就是说,它属于相关子查询,不等于更好的统计方案。

  • 真正要做“每个分类数量”,优先考虑 JOIN + GROUP BY
  • 如果坚持用子查询,必须是和外层分类关联的相关子查询
  • MySQL 8.0+、PostgreSQL 支持在 SELECT 子句里直接放聚合子查询,但 SQLite 不支持

推荐写法:LEFT JOIN + GROUP BY

大多数业务里,LEFT JOIN 配合 GROUP BY 是更适合的默认方案。它能一次拉出所有分类,并把对应产品数量一起统计出来;即使某个分类下暂时没有产品,也会保留这一行,数量显示为 0。

对比 LEFT JOIN + GROUP BY 与相关子查询在结果完整性和执行方式上的差异
两种统计写法的核心差异这张图用于说明为什么大多数场景应优先选择 JOIN 聚合,而不是把子查询当默认解法。
SELECT c.name, COUNT(p.id) AS product_count
FROM categories c
LEFT JOIN products p ON c.id = p.category_id
GROUP BY c.id, c.name;

这条 SQL 的关键点有三个。

为什么要用 COUNT(p.id),不要用 COUNT(*)

COUNT(p.id) 只统计非 NULL 的产品 ID。对于没有匹配产品的分类,LEFT JOIN 之后 p.id 为 NULL,因此结果会正确显示为 0。

如果改成 COUNT(*),数据库会把连接出来的那一行也算进去,空分类就可能被误算成 1。

GROUP BY 为什么要带上 c.id

在这类查询里,GROUP BY 不能只写名称字段。为了保证结果稳定,也为了满足数据库的语法要求,应该把 c.id 和 c.name 一起放进分组条件。

  • PostgreSQL 对这一点要求更严格,缺少必要分组列会直接报错
  • MySQL 在严格模式下也会报错或拒绝执行
  • 把主键和展示字段一起分组,更符合跨数据库兼容的写法

必须用子查询时,应该怎么写

有些场景确实无法回避子查询,比如某些报表工具只允许单层 SELECT 输出,或者现有查询模板不方便改成连接写法。这时可以使用相关子查询,但要明确它只是“可用方案”,不是优先方案。

SELECT c.name,
(SELECT COUNT(*) FROM products p WHERE p.category_id = c.id) AS product_count
FROM categories c;

这条语句的核心在于,子查询内部的 WHERE 条件必须关联外层分类表:

p.category_id = c.id

没有这层关联,内层算出的就只会是总数,而不是当前分类的数量。

相关子查询为什么容易慢

相关子查询的问题不在语法,而在执行方式。数据库通常会对外层查询返回的每一条分类记录,都重复执行一次内层统计;分类越多、产品表越大,代价越明显。

  • 确保 products.category_id 上有索引,否则很容易退化成多次全表扫描
  • MySQL 5.7 默认可能不走索引,实际环境里常需要额外观察执行计划
  • 如果条件允许,优先升级到 MySQL 8.0,必要时再考虑显式加 FORCE INDEX

NULL 分类和脏数据怎么处理

实际库表里,最容易被漏掉的不是 SQL 语法,而是数据本身。比如 category_id IS NULL,或者分类字段被写成空字符串,这些记录往往不会自然落到你期望的分类结果里。

展示未分类记录、空字符串脏数据与单独补统计语句之间关系的白底 SQL 信息图
分类统计里的边界数据处理这张图聚焦统计边界,帮助读者在写对 SQL 之外,也把数据口径补完整。

使用 JOIN 时,没有分类 ID 的记录通常不会匹配到任何分类;使用相关子查询时,它们也不会自动生成一个“未分类”分组。换句话说,SQL 只会按你写下的条件统计,不会替你补业务语义。

统计未分类记录

如果业务上需要把未分类数据单独列出来,可以额外补一条统计语句:

SELECT 'Uncategorized', COUNT(*) FROM products WHERE category_id IS NULL

这样至少能把未归类产品从主统计结果中分离出来,避免被误认为“没有数据”。

空字符串分类先清洗再统计

如果历史数据里存在空字符串分类值,建议先做清洗,再执行正式统计:

UPDATE products SET category_id = NULL WHERE category_id = ''

先统一脏数据口径,再做聚合,通常比在统计 SQL 里不断补条件更容易维护。

结论:默认先选 JOIN,再考虑子查询

只要目标是“统计每个分类的记录数量”,思路就应该先落在“按分类分组”上,而不是先选子查询。通用场景下,LEFT JOIN + GROUP BY 更稳,结果更完整,也更适合处理数量为 0 的分类;相关子查询适合在语法或工具受限时兜底使用,但要额外注意索引和执行成本。

实际写 SQL 时,可以把判断顺序固定下来:先确认是否需要保留空分类,再决定是否必须用子查询,最后检查 NULL 和脏数据是否要单独统计。这样写出来的结果会更接近真实业务口径。

相关标签:

免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。

Python 相关产品和应用

全部下载
Selenium

Selenium

4.51.0 · Windows

用于浏览器自动化和 Web 测试的 Python 工具。

scikit-learn

scikit-learn

1.9.1 · Windows

常用 Python 机器学习算法与模型评估工具库。

Black

Black

26.10.1 · Windows

强调一致性的 Python 自动代码格式化工具。

Ruff

Ruff

0.17.1 · macOS

由 Rust 编写的高速 Python 代码检查和格式化工具。

pytest

pytest

9.1.1 · Windows

简洁而强大的 Python 自动化测试框架。

FastAPI

FastAPI

0.146.0 · Windows

高性能 Python API 框架,支持类型提示和自动接口文档。

Flask

Flask

3.1.3 · Windows

轻量灵活的 Python Web 应用框架。

Django

Django

6.1.2 · Windows

成熟的 Python Web 框架,适合构建内容站点和业务系统。

Spyder

Spyder

6.1.7 · Windows

面向科学计算与数据分析的 Python 集成开发环境。

SciPy

SciPy

1.18.1 · Windows

面向科学、工程和技术计算的 Python 算法库。