
在用户推荐系统中,每个用户都可能存在一个“推荐人”,形成一条类似引荐关系的单向链表,或者多个下级形成树状结构。这类数据通常存储在一张用户表中,只记录直接上级的ID,比如字段 inviter_id。当需要查看某个用户的完整推荐链路时,传统做法是通过多次查询或者代码循环逐层向上获取,直到链路的顶端(没有推荐人的用户)。这种方式不仅效率低,而且当链路层级较深时,会产生大量数据库连接或应用层计算。递归查询(Recursive Query)的出现,使得我们可以在单条SQL语句中完成任意层级的数据检索,极大简化了链路查询的实现。
递归查询的语法与执行原理
多数现代关系型数据库都支持递归查询,例如MySQL 8.0以上、PostgreSQL、SQL Server以及Oracle。它们的核心思路都是通过 WITH RECURSIVE 语句定义一个递归公共表表达式(CTE)。递归 CTE 包含两个部分:初始查询(非递归部分)和递归部分,这两部分通过 UNION ALL 连接。
初始查询获取第一层数据,也就是递归的起点。例如,如果我们想向上追溯某个用户的推荐人链路,初始查询就是查出这个用户自己的记录。递归部分则基于上一次迭代的结果,继续向上或向下寻找相关联的数据。数据库会反复执行递归部分,直到某一次返回空结果集时停止。整个过程类似于一个循环,但由数据库引擎内部完成,比自行在应用层递推要高效得多。
以下是一个最基础的递归查询结构示例,假设有一张 users 表,包含 id、nickname 和 inviter_id 字段:
WITH RECURSIVE user_chain AS (
-- 初始查询:获取目标用户本身
SELECT id, nickname, inviter_id, 0 AS depth
FROM users
WHERE id = 1001
UNION ALL
-- 递归部分:通过 inviter_id 关联到上级用户
SELECT u.id, u.nickname, u.inviter_id, uc.depth + 1
FROM users u
INNER JOIN user_chain uc ON u.id = uc.inviter_id
)
SELECT * FROM user_chain
ORDER BY depth;
上面的例子是向上递归的例子,递归部分通过当前链路的 inviter_id 去找对应的用户,并将层级深度加1。当某条记录的 inviter_id 为 NULL 或者找不到匹配时,递归停止。注意到我们使用了 UNION ALL 而不是 UNION,这是因为递归部分不需要去重,使用 UNION ALL 可以避免不必要的排序操作,提升性能。
向上递归:追溯用户的完整推荐人链路
在业务中,向上追溯最常见的情景就是“查上级”,比如用户想看看是哪个好友推荐了自己注册。假设我们想获取用户 Andy 的所有推荐人,从直接上级一直到最顶层的没有推荐人的用户。利用递归查询,我们只需要将初始查询的起点设置为 Andy 的用户记录,并在递归中一直向上找到 inviter_id 对应的用户即可。
进一步考虑展示需求,我们往往需要将链路结果按从上到下的顺序排列,最顶层排在前面,目标用户排在最后。由于递归查询默认按照深度递增的顺序产出记录,我们可以直接使用深度列排序,或者反转排序。但更直观的做法是设置一个起点深度为0,每次递归深度加1,最终查询时倒序排列,使顶端深度最大,目标用户深度最小。以下示例展示了如何查询用户 id 为 1001 的所有上级链路:
WITH RECURSIVE up_chain AS (
SELECT id, nickname, inviter_id, 0 AS level
FROM users
WHERE id = 1001
UNION ALL
SELECT u.id, u.nickname, u.inviter_id, uc.level + 1
FROM users u
INNER JOIN up_chain uc ON u.id = uc.inviter_id
)
SELECT id, nickname, level
FROM up_chain
ORDER BY level DESC;
这种查询完全避免了多次 SELECT 或循环调用,无论链路的长度是3层还是30层,一条SQL都能返回。需要注意的是,如果存在循环依赖(例如 A 的上级是 B,B 的上级又设置成了 A),递归查询会陷入死循环。预防措施可以在递归部分加入路径记录,检测重复的 id,或者设置递归深度上限(例如在 MySQL 中通过系统变量 cte_max_recursion_depth 控制最大递归次数)。
向下递归:展开用户的全部下级推荐关系树
与向上递归相反,向下递归用于查看某个用户直接或间接推荐了哪些人,形成一个树状的推荐网络。此时初始查询应选定某个用户作为树根,递归部分则改为通过父级的 id 去匹配子级记录中的 inviter_id。通过这种方式,可以轻松得到某个推广员的下级人员列表,甚至是多级下线的完整结构。
例如,我们要查询推广员 Mike(id 为 1000)旗下所有被推荐用户,可以这样写:
WITH RECURSIVE down_chain AS (
SELECT id, nickname, inviter_id, 0 AS depth
FROM users
WHERE id = 1000
UNION ALL
SELECT u.id, u.nickname, u.inviter_id, dc.depth + 1
FROM users u
INNER JOIN down_chain dc ON u.inviter_id = dc.id
)
SELECT id, nickname, inviter_id, depth
FROM down_chain
ORDER BY depth, id;
上述结果集中包含了 Mike 自己以及所有层级的被推荐人。如果不需要包含根节点,可以在初始查询后加入 WHERE 条件过滤,但通常在树状展示中根节点是有意义的。为了更清晰地展现层级关系,我们还可以在查询中使用 LPAD 或 REPEAT 函数生成缩进的前缀,结合 depth 字段在应用层渲染出树状视图。
实际项目中,推荐关系常常需要统计每个用户的邀请人数、邀请带来的收益等。借助于向下递归查询的结果,我们可以将这些聚合操作放在递归结束后进行,比如统计每个上级所拥有的所有下级数量,从而计算佣金。此类查询一般通过将递归结果作为临时表,再与订单表等关联,完成复杂的多维分析。
递归查询的优化与常见陷阱
递归查询虽然强大,但如果不注意细节,很可能导致性能问题甚至死循环。第一个要点是索引设计。递归查询中,递归部分通常会频繁访问连接条件中的字段,例如 u.id = uc.inviter_id 或 u.inviter_id = dc.id。为了加速这些匹配,必须在 users 表的 inviter_id 字段上建立索引,同时 id 通常是主键已被索引,否则大量全表扫描会拖垮数据库。
第二个是循环依赖与深度控制。在数据可能被修改或存在脏数据的情况下,推荐关系容易出现环。除前面提到的路径检测方法外,一种更简单的方式是设置递归深度上限,防止无限循环。MySQL 中可以使用 SET SESSION cte_max_recursion_depth = 100; 来限制最大递归层数,默认值为 1000。生产环境可根据实际业务预期层级进行调整,并配合代码中的异常处理。
第三是数据量控制。向下递归时,如果某个超级推广员的下级数量极其庞大(例如数十万),一次性递归出所有记录可能会导致内存占用过高和查询超时。此时可以考虑分页拉取,或者结合业务场景限制查询深度,比如只查询3级以内的下线。还可以在递归中增加 WHERE 条件提前裁剪不需要的分支,例如只查询某个时间段内注册的下级。
最后,注意不同数据库之间的语法差异。虽然 WITH RECURSIVE 已经成为标准 SQL 的一部分,但在具体函数支持上仍有差别。例如在 PostgreSQL 中可以方便地使用数组存储路径,而 MySQL 8.0 可能需要在递归部分用字符串拼接路径。掌握这些差异,才能在跨数据库环境中游刃有余。