2063 字
10 分钟
重新学习SQL: 完成博客系统的 SQL递归CTE 评论查询设计

一直想给自己的博客添加一个评论功能, 真正写起来才发现评论树的层级嵌套查询并不好做, 在业务层做需要发起多次数据库查询, 而在SQL中使用递归CTE可以很好的解决这一点, 本文主要注重于SQL语句的设计, 真得学了叭

参考

实际需求#

实际评论图

实际评论是可以回复盖楼的, 这就意味着在评论内还有评论, 要实现一个评论树的结构

用户A: 文章不错! (#1)
├─ 贴主: 谢谢(#2)
│ ├─ 用户A: 不客气(#3)
│ │ └─ 用户B: 膜(#4)
│ └─ 用户C: 这文章怎么写的? (#5)
└─ 用户C: 你写得也不差(#6)
用户D: 膜膜(#7)

期望返回结构#

前端构建需要根据评论的深度层级(depth)来调整缩进, 最好有父级id(parent_id)让我知道是回复了谁, 通过评论路径(path)我就可以摘出整个评论回复链

iduser_idparent_idcontentdepthpath
1ANULL文章不错!11
2贴主1谢谢21 -> 2
3A2不客气31 -> 2 -> 3
4B341 -> 2 -> 3 -> 4
5C2这文章怎么写的?31 -> 2 -> 5
6C1你写得也不差21 -> 6
7DNULL膜膜17

CTE入门#

为什么要使用CTE?#

来看下面这个例子, 查询所有换过部门的员工, 思路是在 job_history 部门更换记录表中获取员工编号, 然后通过编号筛选换过部门的员工。此处的(select distinct empno from job_history)就是一个子表达式, 如果这个子查询再复杂些, 或者需要在别的地方复用, 则需要再写一遍, 大大提高了代码的复杂度

select * from employees where empno in (select distinct empno from job_history);

什么是CTE?#

CTE全称Common Table Expression, 是一个命名的临时结果集,它存在于单个语句的范围内,并且可以在该语句中多次引用,而且CTE还可以相互引用。

并且CTE是在所有主流数据库中都通用的表达式(很多数据库都拥有自己的方言, 那这个是不是可以算作数据库中的普通话x)

CTE的基本语法如下:

WITH cte_name (column list) AS query
SELECT * FROM cte_name;

其中cte_name是cte临时表名, (column list)是cte表中的列名, query部分应该用( )包含整个查询语句,

让我们改造一下上文的SQL语句, 查询所有换过部门的员工, 使用CTE实现:

WITH emp_with_history AS (
SELECT DISTINCT empno
FROM job_history
)
SELECT *
FROM employees
WHERE empno IN (SELECT empno FROM emp_with_history);

你会发现原来的子查询被前置了, 先查询作为临时结果集, 后面的查询再从结果集中二次筛选. 虽然看起来比原式稍复杂, 但是有更好的扩展性, 你可以进行多个子查询, 全部都存成CTE, 然后在后文的查询中自由组合使用, 可读性更好.

CTE递归初识#

CTE像是一个函数, 所得到的结果可以反复复用, 甚至可以自己使用自己, 即为”函数递归”, CTE中的递归需要将WITH变成WITH RECURSIVE, 来看一个例子

WITH RECURSIVE cte_table(n) AS (
SELECT 1
UNION ALL
SELECT n+1 FROM cte_table WHERE n < 10
)
SELECT * FROM cte_table;

输出是一个包含n列的表,有10行,值1到10

整个语句用UNION ALL分成两部分, 上半部分只执行一次, 我们暂且称它为锚点查询, 下半部分会对每个锚点数据反复执行, 直到WHERE中的条件不再符合, 最后将每个子查询和对应原始数据拼接在一起形成最终的结果表.

回到例子中, 上半部分SELECT 1直接与cte_table(n)中的n列对应, 代表直接将一个1放入n列, 下半部分对锚点数据1执行, 将n列的数据+1, 并和锚点数据1拼接在一起, 直到n列不小于10

| n |
| --- |
| 1 |
| 2 |
| 3 |
| 4 |
| 5 |
| 6 |
| 7 |
| 8 |
| 9 |
| 10 |

更进一步? 递归CTE生成斐波那契数#

试一试生成斐波那契数的前10项

WITH RECURSIVE fib(n, fib_n, next_fib) AS (
-- 初始值:第1项为0,第2项为1
SELECT 1, 0, 1
UNION ALL
-- 递归:下一项 = 当前项 + 前一项
SELECT n + 1, next_fib, fib_n + next_fib
FROM fib
WHERE n < 10 -- 生成前10项
)
SELECT n AS 序号,
fib_n AS 斐波那契数
FROM fib;
| 序号 | 斐波那契数 |
| ---- | ---------- |
| 1 | 0 |
| 2 | 1 |
| 3 | 1 |
| 4 | 2 |
| 5 | 3 |
| 6 | 5 |
| 7 | 8 |
| 8 | 13 |
| 9 | 21 |
| 10 | 34 |

好了, 你已经掌握了CTE的精髓—递归

简化版: 测试核心功能#

iduser_idparent_idcontenttime
1123NULL文章不错!2026-04-15T08:00
21211谢谢2026-04-15T08:12

在数据库中, 评论数据是按照上述格式存储的

WITH RECURSIVE comment_tree(com_id, user_id, content, depth, path) AS (
SELECT c.id, c.user_id, c.content, 1, c.id from comments AS c
WHERE c.parent_id IS NULL
UNION ALL
SELECT c.id, c.user_id, c.content, ct.depth+1, concat(ct.path, ' -> ', c.id)
FROM comments AS c
JOIN comment_tree AS ct ON ct.com_id = c.parent_id
)
SELECT * FROM comment_tree
ORDER BY path;

该sql返回的内容和上文期望结果相同

在sql中, 使用from comments AS ccomments表起了一个别名c, 接下来需要访问cte表和评论表, 就用表名.列名来区分, 每次计算子节点时都ct.depth+1, 通过concat(ct.path, ' -> ', c.id)拼接出类似于1 -> 2 -> 3的回复链, 后续通过ORDER BY path;自动实现按照评论回复顺序排序

下半部分的JOIN comment_tree AS ct ON ct.com_id = c.parent_id其实就是连接条件, 等价于FROM comments c, comment_tree ct WHERE ct.com_id = c.parent_id, 但是JOIN更加可读

-- 使用 JOIN
SELECT ...
FROM comments c
JOIN comment_tree ct ON ct.com_id = c.parent_id
-- 使用 WHERE
SELECT ...
FROM comments c, comment_tree ct
WHERE ct.com_id = c.parent_id

实际生产环境设计#

在实际博客系统中, 不只是获取用户id, 还需要根据user_id, 在users表中查询对应的用户信息并加入评论返回中, 还需要支持置顶评论, 根评论按最新排序, 子评论按时间先后排序, 评论分页功能

并且简化版的sql中还存在一个漏洞, 直接使用path字符串进行排序, 当评论id达到两位数时, 按照符逐个比较排序会发生10排在2前面, 因为先比较的是"10"中的第一个字符"1""2"

数据库表示例#

comments 表#

idparent_idroot_idarticleuser_idcontenttimepinned
1NULL1/abc101Root A (pinned)2024-01-01 10:00:001
211/abc102Child A12024-01-01 10:05:000
311/abc103Child A22024-01-01 10:10:000
4NULL4/abc104Root B2024-01-02 09:00:000
544/abc101Child B12024-01-02 09:15:000
621/abc105Grandchild A1-12024-01-01 10:20:000

users 表#

idusernameavatarhome_pageroleemail
101alicea.jpgalice.comusera@x.com
102bobb.jpgbob.comuserb@x.com
103charliec.jpgcharlie.comuserc@x.com
104daved.jpgdave.comuserd@x.com
105evee.jpgeve.comusere@x.com

查询SQL#

WITH RECURSIVE comment_tree AS (
SELECT id, id AS com_id, article, user_id, content, browser, os, parent_id, root_id, time, pinned,
1 AS depth,
lpad(id::text, 10, '0') AS path,
time AS root_time
FROM comments
WHERE {root_filter}
UNION ALL
SELECT c.id, c.id AS com_id, c.article, c.user_id, c.content, c.browser, c.os,
c.parent_id, c.root_id, c.time, c.pinned,
ct.depth + 1 AS depth,
concat(ct.path, ' -> ', lpad(c.id::text, 10, '0')) AS path,
ct.root_time
FROM comments AS c
JOIN comment_tree AS ct ON ct.com_id = c.parent_id
WHERE c.article = :article
)
SELECT ct.id, ct.article, ct.user_id, ct.content, ct.browser, ct.os,
ct.parent_id, ct.root_id, ct.time, ct.depth, ct.path, ct.pinned,
u.username, u.avater, u.home_page, u.role, u.email
FROM comment_tree ct
LEFT JOIN users u ON ct.user_id = u.id
ORDER BY ct.pinned DESC, ct.root_time DESC, ct.path ASC
OFFSET :skip LIMIT :limit

该SQL相较于简化版有着以下明显改进

类别简化版最终版
根节点过滤WHERE c.parent_id IS NULL
(固定取所有根评论)
WHERE {root_filter}(占位符,可动态指定根节点,
比如 root_id = 某值parent_id IS NULL AND article = :article)
文章过滤WHERE 子句中添加了 c.article = :article
确保子评论与根评论属于同一篇文章
路径构建concat(ct.path, ' -> ', c.id)
路径如 1 -> 2 -> 3
lpad(..., 10, '0') 将 ID 补零成固定宽度(如 0000000001)
保证按字符串排序时能按数字顺序排列 (避免 10 排在 2 前面)
排序与分页置顶评论(pinned)优先
然后按根评论时间倒序
同根下的评论按 path 字典序(即树的深度优先顺序)
支持分页(OFFSET / LIMIT)

最终查询结果#

{root_filter} = parent_id IS NULL AND article = :articlearticle=/abcskip=0limit=100,排序 pinned DESC, root_time DESC, path ASC

idarticleuser_idcontentparent_idroot_idtimedepthpathpinnedusernameavatarhome_pageroleemail
1/abc101Root A (pinned)NULL12024-01-01 10:00:00100000000011alicea.jpgalice.comusera@x.com
2/abc102Child A1112024-01-01 10:05:0020000000001 -> 00000000020bobb.jpgbob.comuserb@x.com
6/abc105Grandchild A1-1212024-01-01 10:20:0030000000001 -> 0000000002 -> 00000000060evee.jpgeve.comusere@x.com
3/abc103Child A2112024-01-01 10:10:0020000000001 -> 00000000030charliec.jpgcharlie.comuserc@x.com
4/abc104Root BNULL42024-01-02 09:00:00100000000040daved.jpgdave.comuserd@x.com
5/abc101Child B1442024-01-02 09:15:0020000000004 -> 00000000050alicea.jpgalice.comusera@x.com
重新学习SQL: 完成博客系统的 SQL递归CTE 评论查询设计
https://www.mintlab.top/posts/learn/sql-cte/
作者
Mint
发布于
2026-04-15
许可协议
CC BY-NC-SA 4.0
发表评论

输入用户名和邮箱后自动检查登录状态。登录后用户名和邮箱将被绑定, 只可以修改头像和主页链接。

未登录
昵称
邮箱
填写头像链接与主页链接

头像链接为空默认使用gravatar头像

头像
主页
人机验证
评论列表

以下是可爱的评论们:

暂无评论, 呜呜, 快来评论喵!