☰
SQL多表查询、事务与DCL:数据库读写到权限管理实战
2026/10/21 6:27:59 网站建设 项目流程

如果你正跟着数据库学习计划走到第四天,前面的建库建表、增删改查和单表查询已经练得比较熟,那今天要聊的多表查询、事务和DCL,就是把你从“单表工具人”变成“能设计真实业务数据操作”的分水岭。很多人觉得多表查询就是多写几个JOIN,事务就是BEGIN和COMMIT,DCL就是GRANT,但真到了项目里,Join几条大表跑出来的结果不对,事务并发出现脏数据,权限开太大被运维找上门,才发现每一个点都值得认真抠。这篇文章按Day04的学习主线展开,讲清三块内容的核心逻辑、实操命令和避坑经验,适合正在系统学SQL的人,也适合工作一两年想补基础的同学。读完不需要记太多花哨语法,只要跟着例子走一遍,再回头看日常脚本里那些“不知道为什么能跑”的SQL,会有新的理解。

1. 为什么把多表查询、事务、DCL放在同一天学习

1.1 三个主题指向的是同一个核心能力

实际业务里的数据几乎不会只待在一张表里。用户表放用户信息,订单表放订单信息,订单明细表放商品信息,它们之间靠外键关联。只有多表查询能把它们按照关系拼起来,让前端页面直接拿到一个完整结果集。但光会读是不够的,写操作还必须考虑一致性。下单这个动作,可能同时要扣库存、生成订单、记录日志,任何一步失败都不能让其他步骤“半成功”。事务就是让多条写操作变成一个整体。再往下,谁有权限执行这些操作?DCL就是管这个的。

所以你会发现,多表查询、事务、DCL分别在“读”“写”“管”三个层面协作。前三天学的是单表内部的操作,第四天开始跳出单表,去处理表与表之间、操作与操作之间、用户与资源之间的关系。Day04把它们放在一起,不是为了凑知识点,而是希望学习者在同一个阶段建立起完整的数据操作视角,而不是孤立地背命令。

1.2 我的学习顺序建议

我见过不少同学今天急着把 JOIN 花式写法全背下来,明天又想把事务隔离级别整明白,最后遇到问题仍然一脸懵。正确顺序应该按数据流来走:先用一套简单业务表把多表查询写熟,再通过一个转账场景理解事务,最后按角色分配权限。多表查询影响的是读,事务影响的是写,DCL影响的是管理边界,把读、写、管分开练,遇到问题才能准确定位。

举个例子,一个订单列表页面要显示客户姓名、订单编号、总金额、商品清单,如果只会单表查询,你得先查订单再循环查客户,然后在业务代码里组装。学会多表查询后,一条JOIN就能直接把数据取到同一结果集。订单提交时则要考虑事务,比如下单同时要扣库存、加积分、生成物流单,任何一步失败都应该回滚。至于账号权限,开发账号、只读账号、管理账号必须分得清清楚楚,否则上线第一天就会被运维约谈。这三部分连起来,正好是一套真实项目数据操作的最小闭环。

2. 多表查询:会JOIN是第一步,知道为什么是JOIN才是进阶

多表查询先聊,因为它最容易做出直观结果,也最容易出错。很多人写 JOIN 非常熟练,但问他为什么用左连接不用内连接,为什么子查询里 NOT IN 会返回空,就答不上来。这一章我们不用复杂案例,就建一套能练手的小表,把连接查询、自连接、子查询这些点逐个跑通。

2.1 先建一套能练手的业务表

在实际项目里我不建议直接拿生产环境表练手,强烈建议自建一个迷你订单模型。下面这三张表足够覆盖大部分连接场景:

CREATE TABLE customer ( id INT PRIMARY KEY, name VARCHAR(50) ); CREATE TABLE orders ( id INT PRIMARY KEY, customer_id INT, total_amount DECIMAL(10,2), CONSTRAINT fk_customer FOREIGN KEY (customer_id) REFERENCES customer(id) ); CREATE TABLE order_item ( id INT PRIMARY KEY, orders_id INT, goods_name VARCHAR(50), price DECIMAL(10,2), CONSTRAINT fk_orders FOREIGN KEY (orders_id) REFERENCES orders(id) );

插入模拟数据:

INSERT INTO customer VALUES (1,'张三'),(2,'李四'),(3,'王五'); INSERT INTO orders VALUES (101,1,500.00),(102,1,300.00),(103,2,800.00); INSERT INTO order_item VALUES (1001,101,'键盘',200.00), (1002,101,'鼠标',300.00), (1003,103,'显示器',800.00);

为什么故意让王五没有订单,让张三有两张订单,让订单102没有商品明细?就是为了练出“连接后缺数据”和“连接后多数据”的效果。很多初学者拿到的练习数据整整齐齐,反而测不出 JOIN 的边界行为。数据本身不干净,是真正业务表和练习表最大的区别。

2.2 内连接:两张表都满足连接条件才会出现

查询所有下过单的客户以及订单金额:

SELECT c.name, o.total_amount FROM customer c INNER JOIN orders o ON c.id = o.customer_id;

结果里会有张三和李四,王五不会出现。这就是内连接的语义:取两表的交集。面试常问的INNER JOIN和 WHERE 里写笛卡尔积过滤没有本质区别,但 JOIN 语法把连接条件和筛选条件分开了,可读性更好。比如只需要看金额大于400的订单,可以在后面加WHERE o.total_amount > 400。注意不要把连接条件也塞进 WHERE,除非你写的是老式逗号连接。

这个例子看似简单,但你要理解一个关键点:内连接只返回匹配成功的行。真实业务里“查所有下单客户”和“查所有客户及其订单”的差别,就是内连接和外连接的差别。用错连接类型,最典型的问题就是少数据。

2.3 外连接:主表数据必须保留,副表没有就用NULL补

业务中经常需要“所有客户都要显示,没下单的也要显示”,这时用左连接:

SELECT c.name, o.id, o.total_amount FROM customer c LEFT JOIN orders o ON c.id = o.customer_id;

王五会返回一行,订单相关字段是NULL。注意 LEFT JOIN 的结果行数至少等于左表行数,如果左表一条记录对应右表多条记录,行数会膨胀。右外连接只是把主表换到右边:

SELECT c.name, o.id, o.total_amount FROM orders o RIGHT JOIN customer c ON o.customer_id = c.id;

MySQL 没有全外连接,想要“两边都保留”时,只能用左连接和右连接的 UNION 模拟。实际项目里我基本只用 LEFT JOIN,因为大家习惯把主表写在前面,这样先读主表、再补副表字段,逻辑非常顺。真正要注意的是连接后行数膨胀的问题,如果你需要的是“每个客户一行”,然后关联出他的订单聚合结果,就不能直接 JOIN 订单表后不管。

2.4 自连接:一张表和自己比较,必须给表起别名

自连接最常见的场景是员工表里存着上级ID。假设有一张 emp 表:

CREATE TABLE emp ( id INT PRIMARY KEY, name VARCHAR(50), manager_id INT ); INSERT INTO emp VALUES (1,'赵老板',NULL), (2,'钱主管',1), (3,'孙专员',2);

查询每个员工及其上级姓名:

SELECT e.name AS 员工, m.name AS 上级 FROM emp e LEFT JOIN emp m ON e.manager_id = m.id;

这里如果不给表起别名,同一个表出现两次会直接报错。自连接的本质是把一张表“复制”成两份逻辑表再关联,所以别名不是可选项,而是必须项。查询结果里老板的上级是NULL,符合预期。自连接除了员工上级场景,还常用于商品分类、组织树、关系链等数据模型,凡是同一张表里的行之间存在层级关系,基本都逃不掉自连接。

2.5 子查询和EXISTS的使用边界

子查询不一定比 JOIN 差,要分场景。先看最直观的:查“没有下过单的客户”,用 NOT IN 或 NOT EXISTS:

SELECT * FROM customer WHERE id NOT IN ( SELECT customer_id FROM orders );

如果orders.customer_id里有 NULL,NOT IN 会返回空结果,因为NOT IN (NULL, ...)的结果是 UNKNOWN,WHERE 只保留 TRUE,所以全部不返回。这一点我见很多人踩坑。更稳的写法是 NOT EXISTS:

SELECT * FROM customer c WHERE NOT EXISTS ( SELECT 1 FROM orders o WHERE o.customer_id = c.id );

EXISTS 只要子查询返回一行就为真,性能上通常适合“判断存在性”;而 IN 适合返回少量确定值列表。现在 MySQL 优化器有能力做很多改写,但语义差异依然存在。我在项目中习惯用 EXISTS 做关联子查询,用 IN 做不相关的集合判断,这样的代码自己回看时也容易理解。

如果你还想深挖性能,可以在这些 SQL 前面加EXPLAIN,观察执行计划里的 type 字段和驱动表。优化器大多数时候是靠谱的,但如果发现选错了驱动表,可以用STRAIGHT_JOIN强制指定,不过这是调优话题,Day04 阶段先把语义写对更重要。

3. 事务:ACID不是眼前一排文字,而是一套保护数据的规则

多表查询解决的是读,接下来看写。写操作最怕的不是写错,而是写到一半“卡住”,留下一堆不一致的数据。事务就是让多条写操作成为一个整体,要么全成功,要么全失败。

3.1 一个转账场景把事务说清楚

假设有账户表:

CREATE TABLE account ( id INT PRIMARY KEY, name VARCHAR(50), balance DECIMAL(10,2) ); INSERT INTO account VALUES (1,'张三',1000),(2,'李四',1000);

转账逻辑:张三账户减100,李四账户加100。这两条 UPDATE 必须作为一个整体。如果没有事务,第一条执行后程序崩溃,张三钱少了,李四没收到,谁都说不清楚。用事务包起来后,要么两条都提交,要么都回滚。

事务的四个特性,我建议结合场景理解而不是死背。原子性指一组操作不可分割;一致性指事务完成前后数据满足约束,比如转账前后总余额不变;隔离性指多个事务并发时互不干扰;持久性指提交后的修改不会因为数据库重启而丢失。面试时如果只会背这四个英文单词,很容易被追问噎住,但如果你能从转账、库存、流水这些真实场景里各举一个例子,说服力完全不同。

3.2 事务命令实操:提交、回滚、保存点

在 MySQL 命令行里执行:

START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; COMMIT;

如果在中途发现第二条 UPDATE 条件写错,可以执行ROLLBACK;,两条更新都会撤销。还有更细的控制方式:

START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE id = 1; SAVEPOINT after_debit; UPDATE account SET balance = balance + 100 WHERE id = 2; -- 发现加错了,回滚到保存点 ROLLBACK TO after_debit; RELEASE SAVEPOINT after_debit; COMMIT;

这样只回滚保存点之后的修改,第一条扣款仍然保留。保存点适合处理长事务里的局部错误,但如果业务逻辑已经复杂到需要保存点,我会建议直接拆成多个事务,因为长事务本身会持有锁更久,容易造成锁等待。

这里有几个非常容易踩的坑。MySQL 默认每条单独的 SQL 都是自动提交,所以你不写 START TRANSACTION,每条 UPDATE 就是独立事务,失败不会回滚之前的语句。另外 InnoDB 才支持事务,MyISAM 不支持,如果你用的旧表是 MyISAM,事务看起来就是“无效”的。还有一点:DDL 语句会隐式提交当前事务,比如事务里执行 CREATE TABLE,之前未提交的修改会直接提交,这点很反直觉,但在日常脚本里经常遇到。

3.3 隔离级别:并发事务之间到底隔离到什么程度

并发事务可能产生三类问题:

  • 脏读:事务A修改了数据但未提交,事务B读到修改后的值,A回滚后B的数据就成了幻觉。
  • 不可重复读:事务B前后两次读同一行,第一次读到100,期间事务A提交改成200,第二次变成200,同一事务内读到的值不稳定。
  • 幻读:事务B按条件查询出一个结果集,期间事务A插入了符合条件的新记录,事务B再次按同样条件查询,多出原本不存在的行。

不同隔离级别解决不同问题:

隔离级别脏读不可重复读幻读
读未提交 READ UNCOMMITTED可能可能可能
读已提交 READ COMMITTED不会可能可能
可重复读 REPEATABLE READ不会不会标准SQL可能,InnoDB通过间隙锁基本避免
串行化 SERIALIZABLE不会不会不会

MySQL 默认隔离级别是 REPEATABLE READ,和 Oracle 默认 READ COMMITTED 不同。查看当前隔离级别:

SELECT @@transaction_isolation;

练习时可以开两个会话,分别执行SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;,再观察事务B在事务A提交前后读到的值。不用死记,自己跑一遍比背十遍都牢。

3.4 锁、死锁和事务性能

事务并发写入需要加锁。SELECT ... FOR UPDATE可以手动对读取的行加排他锁,但加锁范围要清楚:如果 WHERE 条件带索引,锁的是具体行或区间;如果 WHERE 条件列没有索引,InnoDB 可能锁表,性能下降明显。

死锁最常见的原因是两个事务以不同顺序更新相同资源。事务A先锁账号1再想锁账号2,事务B先锁账号2再想锁账号1,互相等待。InnoDB 会检测到死锁并回滚其中一方,报Deadlock found。避免死锁的实操原则有三条:固定更新顺序,事务尽量短,不要在事务里做耗时查询或等待用户输入。这三个原则平时看着简单,真正设计代码时最容易违反。

4. DCL:权限管理做得乱,数据库早晚出事故

读和写都搞清楚了,最后一道管住边界的门就是 DCL。相比多表查询和事务,DCL 的命令并不多,但它的重要性在真实环境里被严重低估。很多项目上线后出事故,根因不是SQL写错,而是权限开太大。

4.1 用户是权限的最小单元,且要区分主机

MySQL 中的用户不像其他系统只是一个名字,而是“用户名@可登录主机”的组合。例如'app_user'@'localhost'只允许本机连接,'app_user'@'%'允许任意主机连接,它们是两个不同账号。练习时创建用户:

CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'App@123456';

创建后可以查看权限:

SHOW GRANTS FOR 'app_user'@'localhost';

这里要特别记住:MySQL 8.0 开始不支持直接GRANT ALL ON ... TO '新用户'@'主机' IDENTIFIED BY ...来创建用户,必须先 CREATE USER,再 GRANT。这个行为变化让很多从老版本习惯带过来的同事报错过。

4.2 GRANT授权:精确到库、表、列

GRANT 的基础语法:

GRANT SELECT, INSERT, UPDATE ON db_shop.orders TO 'app_user'@'localhost';

如果希望一个账号对某个库有全部权限,可以写成:

GRANT ALL PRIVILEGES ON db_shop.* TO 'app_user'@'localhost';

DCL 还能精确到列级权限。比如只允许某个账号查询 customer 表的 name 字段:

GRANT SELECT (name) ON db_shop.customer TO 'clerk'@'localhost';

授权之后如果发现不生效,执行FLUSH PRIVILEGES;。虽然大多数情况下 GRANT 语句会直接修改内存中的权限表,但刚接触 DCL 时养成刷新习惯没坏处。更重要的是,每次授权后都要用SHOW GRANTS FOR '账号'@'主机';确认一下,权限这个东西,嘴上说“给了”不算,查出来才算。

4.3 REVOKE回收权限与删除用户

回收权限:

REVOKE SELECT ON db_shop.customer FROM 'clerk'@'localhost';

撤销某用户的所有权限:

REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'app_user'@'localhost';

删除账号:

DROP USER 'app_user'@'localhost';

注意 GRANT OPTION 是一种特殊权限,代表账号能否把权限继续转授给其他人。回收时如果不带 GRANT OPTION,某些拥有转授权限的账号可能依然能把权限传出去,所以完整回收要写全。这一点很多文章不会专门提,但恰恰是 DCL 最容易被忽视的安全隐患。

4.4 权限分配的最小化和日常审查

项目中的常见错误是给应用程序账号开通root,或者干脆开整个库的ALL PRIVILEGES。开发环境可以忍,生产环境一旦出现 SQL 注入或者误操作,就是大事故。我见过一个项目给只读报表账号也开了 DELETE 权限,结果有人在测试环境顺手清了一张历史表,恢复数据花了整整一天。

正确做法是:按模块拆账号。订单服务只用能操作 orders 相关表的账号,报表服务用只读账号,后台管理账号才允许 DELETE 和 UPDATE。日常定期检查:

SELECT user, host, plugin FROM mysql.user; SHOW GRANTS FOR 'app_user'@'localhost';

修改密码用:

ALTER USER 'app_user'@'localhost' IDENTIFIED BY 'NewPass@123';

权限管理是运维的边界,也是对业务负责。Day04 里 DCL 命令虽然最少,但在真实公司里最容易被忽略,也最容易在出问题时被追责。

5. 常见问题与避坑实录

这一章更像我的个人排错笔记,把多表查询、事务、DCL 三个主题里高频踩过的坑集中列一下。你以后遇到类似报错,可以直接对照排查。

5.1 多表查询结果行数为什么膨胀了

一个客户对应多个订单,JOIN 订单表后每个订单都会带出客户信息,所以客户行会重复。如果需求是“取客户列表并显示第一个订单”,直接 JOIN 后 GROUP BY 很容易取错。我常用的办法是先按客户聚合订单,再关联,或者用窗口函数。不要以为 JOIN 一定只是“拼接”,要留意关联键是否一对多,业务语义是“一行客户对应多行订单”还是“一行订单对应一行客户”。

5.2 事务好像没生效

先确认表引擎是否为 InnoDB:

SHOW TABLE STATUS LIKE 'account';

再查看是否自动提交:

SELECT @@autocommit;

如果为1但没写 START TRANSACTION,每条 UPDATE 都会独立提交。还有一种情况是事务里执行了 DDL,导致隐式提交。排查事务问题,最重要的一点是把终端拆成两个会话,别在同一个查询窗口里既写又查,否则你看到的“成功回滚”可能只是假象。

5.3 NOT IN 遇到 NULL 直接查不出来

前面已经说过,WHERE id NOT IN (SELECT customer_id FROM orders)如果 orders.customer_id 有空值,最终结果集为空。原因不是数据真的不存在,而是 SQL 三值逻辑里 NULL 参与 NOT IN 的结果是 UNKNOWN,WHERE 只保留 TRUE。解决方案是不允许该列出现 NULL,或者在子查询里先过滤掉 NULL,或者改用 NOT EXISTS。我个人最推荐 NOT EXISTS,因为它语义更清晰,也不容易受到 NULL 干扰。

5.4 权限报错:Access denied 但账号确实存在

先用 SHOW GRANTS 看账号权限,再确认当前连接用的主机是否匹配。比如账号是'app_user'@'%',客户端从本机连接时 MySQL 可能会优先匹配更精确的'app_user'@'localhost',而本机账号没有权限,因此报错。解决方式是把两处主机指向统一,或者把授权语句明确写到对应的主机范围。

5.5 锁等待超时怎么办

出现Lock wait timeout exceeded时,用SHOW PROCESSLIST;找到处于 Waiting for lock 的会话,再用KILL 线程ID;结束阻塞源。根本解法是优化事务时长、检查是否忘记提交。这里有一个很实用的习惯:在事务代码里设置超时时间,比如在 JDBC 层设置事务超时,避免一条事务占着锁把整张表堵死,拖垮所有业务请求。

DCL权限整理这件事,我一直建议放在项目上线清单里,越早做越省心。每次发布前把开发库、测试库、生产库的账号权限列成一张表,逐条核对谁有写权限、谁有删权限、谁只有只读权限,很多夜间告警都能提前避免。Day04的内容大概就是这样,如果后面你练到调优阶段再看这些命令,会发现当初觉得不起眼的细节全都有它的用途。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询