必须用中间表,不能跳过;三表JOIN需遵循主表→中间表→业务表路径,漏掉中间表或写错ON条件会导致结果为空或笛卡尔积爆炸,因user与role无直接外键关联,强行直连会报错或产生无意义数据。

必须用中间表,不能跳过;三表 JOIN 的顺序不重要,但路径不能断——漏掉中间表或写错 ON 条件,结果要么全空,要么爆炸式重复。
为什么不能直接 JOIN 两张业务表?
因为 user 和 role 表之间没有外键,数据库根本不知道它们怎么关联。强行写 SELECT * FROM user u JOIN role r ON u.id = r.user_id,r.user_id 字段压根不存在,MySQL 直接报错;换成 WHERE u.id = r.id 这类硬凑条件,结果就是笛卡尔积——100 个用户 × 50 个角色 = 5000 行垃圾数据,毫无业务意义。
常见翻车点:
- 把中间表字段名写错,比如用了
uid/rid而不是统一的user_id/role_id,JOIN 条件失效 - 中间表没建联合索引,
user_id和role_id单独有索引没用,查得慢还容易锁表 - 误以为“多对多”能靠 JSON 或逗号分隔字符串模拟,导致
LIKE '%3%'全表扫描、无法加索引、更新不原子
三表 JOIN 的标准路径怎么写?
核心就一条:主表 → 中间表 → 业务表。谁是主表,取决于你要什么结果——要用户列表就以 user 为主;要角色列表就以 role 为主。
查「所有用户及其分配的角色(含无角色者)」:
SELECT u.id, u.name, r.name AS role_name FROM user u LEFT JOIN user_role ur ON u.id = ur.user_id LEFT JOIN role r ON ur.role_id = r.id;
查「有角色 3 的所有用户」(注意过滤条件位置):
SELECT u.id, u.name FROM user u INNER JOIN user_role ur ON u.id = ur.user_id AND ur.role_id = 3 INNER JOIN role r ON ur.role_id = r.id;
关键区别:
-
ur.role_id = 3放在ON里,INNER JOIN才能精准命中;放WHERE里会先 JOIN 再过滤,逻辑一样但可读性差 - 如果用
LEFT JOIN却把ur.role_id = 3放WHERE,等于把左连接“降级”成内连接,没角色的用户全被踢掉 - 两次
JOIN的连接字段必须严格对应:u.id = ur.user_id和ur.role_id = r.id,别混成u.id = r.user_id
怎么合并多个角色到一行?
默认 JOIN 后一个用户有 N 个角色,就会返回 N 行相同用户数据。要压成一行,必须聚合。
MySQL 写法:
SELECT u.id, u.name, GROUP_CONCAT(r.name ORDER BY r.name) AS roles FROM user u LEFT JOIN user_role ur ON u.id = ur.user_id LEFT JOIN role r ON ur.role_id = r.id GROUP BY u.id, u.name;
PostgreSQL 写法:
SELECT u.id, u.name, STRING_AGG(r.name, ', ' ORDER BY r.name) AS roles FROM user u LEFT JOIN user_role ur ON u.id = ur.user_id LEFT JOIN role r ON ur.role_id = r.id GROUP BY u.id, u.name;
踩坑提醒:
-
GROUP_CONCAT默认长度上限 1024,超长会被截断,上线前务必执行SET SESSION group_concat_max_len = 10000 - MySQL 5.7+ 严格模式下,
SELECT里所有非聚合字段都必须出现在GROUP BY中,否则报错 -
STRING_AGG在 PostgreSQL 9.6 以下版本不支持ORDER BY子句,得套子查询
中间表带状态字段时怎么过滤?
如果 user_role 表里有 is_active TINYINT 或 created_at DATETIME 这类字段,过滤逻辑必须放在 ON 里,而不是 WHERE。
错误写法(丢失无有效角色的用户):
LEFT JOIN user_role ur ON u.id = ur.user_id LEFT JOIN role r ON ur.role_id = r.id WHERE ur.is_active = 1;
正确写法(保留主表全部记录,只过滤中间表关联路径):
LEFT JOIN user_role ur ON u.id = ur.user_id AND ur.is_active = 1 LEFT JOIN role r ON ur.role_id = r.id;
更复杂的情况:想查「用户 + 最新分配的角色」,就得在中间表上加时间排序,再用窗口函数或子查询取 top 1——这时候中间表已不只是桥梁,而是带业务语义的实体了。

















