数据库学到第四天,工具层面的SQL语法基本都过了一遍,单表查询、过滤、分组、排序这些操作,估计你闭着眼也能写出来。但一到实际项目里,你会发现真正难的不是单表,而是表跟表之间那一堆剪不断理还乱的关系。Day04这个安排很讲究,把多表查询、事务、DCL放在同一天讲,实际上是在告诉你三件事:怎么把数据查明白、怎么把数据改安全、怎么把数据权限管住。这三点配合起来,你才敢说自己真正入门了数据库。
我见过不少同学,单表JOIN写得飞起,但一遇到三个表以上的关联就头脑发懵;也有人CRUD都没问题,却在并发扣库存的时候把数据改坏了;还有人连root账号一路用到上线,结果数据库被人拖走了都不知道问题出在哪。Day04这三块内容,恰恰就是来补这些窟窿的。文章不会照搬教材,我会直接用实际工作里的例子,把多表查询的关联逻辑、事务的隔离级别、DCL的授权模型讲透,最后再贴几个我踩过的坑。
1. 多表查询:单表是基础,多表才是日常
1.1 为什么单表不够用:范式化拆分的代价
上学的时候老师总强调“三大范式”,当时我特别不理解:明明一张表把数据都塞进去,查起来多方便,为什么要拆成好几张表?后来在业务里被数据冗余折磨过几次,才明白范式化拆分是对的。用户表存基础信息,订单表只存用户ID,商品表只存商品ID,订单明细表再把订单ID和商品ID关联起来。这样改用户昵称只需要更新一处,不会出现同一个用户在不同订单里留下不同昵称的脏数据。
但拆完之后问题就来了:你想查“某个订单对应的用户叫什么名字”,数据分别在两张表里,怎么一次查出来?这时候就需要JOIN。JOIN的本质是把多张表在逻辑上拼成一张“大宽表”,然后在这张大宽表上继续WHERE、GROUP BY、ORDER BY。你可以把JOIN理解成拉链:左边表的每一行,去右边表找匹配的行,匹配上了就拉在一起,匹配不上就看用哪种拉法。
实际项目里,几乎不会有人满屏“SELECT * FROM orders”,因为订单表存的是user_id,客户看到的是user_name。没做关联查询,你写出来的报表根本没法看。所以多表查询不是知识点,是生存技能。
1.2 JOIN 的三种核心写法与场景
先造两张简单的表,后面所有例子都基于它们:
-- 用户表 CREATE TABLE user_info ( uid INT PRIMARY KEY, name VARCHAR(50), age INT ); -- 订单表 CREATE TABLE order_info ( oid INT PRIMARY KEY, user_id INT, amount DECIMAL(10,2), status TINYINT ); INSERT INTO user_info VALUES (1,'张三',25),(2,'李四',30),(3,'王五',22); INSERT INTO order_info VALUES (101,1,199.00,1),(102,1,299.00,0),(103,2,99.00,1);假设李四还下过一单,订单表里也插进去了;王五没有订单。这时候想查每个订单的归属人,用INNER JOIN:
SELECT o.oid, o.amount, u.name FROM order_info o INNER JOIN user_info u ON o.user_id = u.uid;这个查询只会返回订单表里user_id能在用户表中匹配到的行。张三的两单、李四的一单都在,王五因为没订单自然不出现。如果你想要“所有用户各自下了多少单,包括没下过单的人”,INNER JOIN就不行了,得用LEFT JOIN:
SELECT u.uid, u.name, COUNT(o.oid) AS order_cnt FROM user_info u LEFT JOIN order_info o ON u.uid = o.user_id GROUP BY u.uid, u.name;LEFT JOIN的意思是:左表(user_info)的行全部保留,右表有匹配就带上,没有匹配就用NULL填充。所以王五也会出现,order_cnt是0。
这里有个经典坑:LEFT JOIN之后,如果你在WHERE里加了右表的条件,比如“只要status=1的订单”,当王五的右表字段全是NULL时,WHERE o.status = 1会把王五这一行过滤掉,LEFT JOIN白做了。
-- 错误示范:用户状态被过滤,王五消失 SELECT u.name, o.amount FROM user_info u LEFT JOIN order_info o ON u.uid = o.user_id WHERE o.status = 1;正确做法是把这个条件挪到JOIN的ON子句里:
SELECT u.name, o.amount FROM user_info u LEFT JOIN order_info o ON u.uid = o.user_id AND o.status = 1;这是左连接的精髓:ON决定“怎么匹配”,WHERE决定“匹配完之后留哪行”。顺序搞反,语义就错。
我把三种连接的差别整理了一下,方便对照:
| 连接类型 | 效果 | 典型场景 |
|---|---|---|
| INNER JOIN | 两边都匹配的行才出 | 查有订单的用户、有关联明细的主单 |
| LEFT JOIN | 左表全出,右表无匹配补NULL | 查未下单用户、查主表带不从表数据 |
| RIGHT JOIN | 右表全出,左表无匹配补NULL | 少用,通常把表顺序换一下用LEFT JOIN替代 |
| FULL JOIN | 两边全出,无匹配补NULL | MySQL原生不支持,可用UNION模拟 |
另外一个容易忽略的点:ON后面是关联条件,WHERE后面是过滤条件。两个条件都可以写进JOIN语句,但它们执行时机不同。INNER JOIN里结果一样,但LEFT/RIGHT JOIN里差异巨大。
1.3 子查询与 EXISTS:什么时候不该用 JOIN
JOIN不是万能的。有些场景子查询更清晰,有些场景EXISTS性能更好。
比如你想查“所有下过单的用户信息”,可以直接JOIN然后DISTINCT,但写起来绕:
SELECT DISTINCT u.* FROM user_info u INNER JOIN order_info o ON u.uid = o.user_id;用子查询也能写:
SELECT * FROM user_info WHERE uid IN (SELECT user_id FROM order_info);这段SQL可读性更好,逻辑一眼就懂。但它有个隐患:如果子查询返回的数据量很大,有些数据库版本对IN列表性能不友好。这时候EXISTS往往更快,因为它是“只要有一条匹配就停止扫描”,不用把整个结果集算出来:
SELECT * FROM user_info u WHERE EXISTS ( SELECT 1 FROM order_info o WHERE o.user_id = u.uid );注意EXISTS后面子查询的SELECT列表写1、写*都无所谓,因为EXISTS不关心返回什么,只关心有没有行。有些同学写半天写不对,是因为没理解相关子查询的概念——EXISTS子查询里的o.user_id = u.uid,这个u.uid是外层查询传进来的当前行的值,子查询对外层每一行都要执行一次。
还有一个坑是NOT IN遇上NULL。如果order_info.user_id列存在NULL,WHERE uid NOT IN (SELECT user_id FROM order_info)会查不出任何数据。原因是SQL的三值逻辑:NULL参与比较时结果是UNKNOWN,UNKNOWN被当作不成立。稳妥做法是写NOT EXISTS,或者在子查询里过滤掉NULL。
日常开发里我有个习惯:关联表不超过三张时优先JOIN,超过三张或需要多层嵌套时拆成子查询或临时表。JOIN太多会让执行计划变得复杂,SQL可读性也会断崖式下跌。等你们去真实环境看那些动辄几百行的大SQL,就会明白“让人能看懂”是多么重要。
2. 事务:保证数据安全的最后一层防线
2.1 转账场景里的 ACID
先别急着写代码,想一个场景:你给朋友转账1000块,应用程序要做两件事,先在你的账户扣款,再在对方账户加款。如果第一步执行完、第二步还没执行,数据库崩了,会发生什么?钱扣了,但对方没收到。这就是典型的“数据不一致”。
事务就是来解决这个问题的。事务把一组操作打包成一个不可分割的单元,要么全部成功,要么全部失败。用生活的例子类比,事务就像饭店里的“厨房出菜流程”:不是炒完一个菜就端给你,而是全部菜做完再一起上。中途任何一道菜糊了,整桌菜全部重做,客人绝不吃到“一半菜”的饭。
这个“要么全成、要么全不成”的特性,背后就是ACID:
| 特性 | 含义 | 典型破坏场景 |
|---|---|---|
| Atomicity 原子性 | 事务内操作不可分割 | 中途崩溃,只执行了一半 |
| Consistency 一致性 | 事务前后数据都符合约束 | 扣款和加款金额不等 |
| Isolation 隔离性 | 并发事务互不干扰 | 两个事务同时改同一个数据 |
| Durability 持久性 | 事务提交后不能丢 | 刚提交完数据库断电 |
很多人背ACID背得滚瓜烂熟,但实际写代码时根本没意识到自己破坏了它。最常见的破坏方式就是:业务代码里自己写多条UPDATE,中间不包事务,结果跑任务时网络抖一下,掉了一条,日志里一堆error,数据表里留下一堆对不上账的记录。等你发现,已经没法追溯到是哪一单扣了没加。
2.2 事务控制语句:BEGIN、COMMIT 与 ROLLBACK
MySQL里事务操作不复杂,就三个关键命令:
-- 开始一个事务 START TRANSACTION; -- 执行你的业务操作 UPDATE account SET balance = balance - 1000 WHERE uid = 1; UPDATE account SET balance = balance + 1000 WHERE uid = 2; -- 确认无误,提交事务 COMMIT;如果执行到第二条UPDATE时发现账户2不存在,或者余额不对,你可以主动回滚:
START TRANSACTION; UPDATE account SET balance = balance - 1000 WHERE uid = 1; -- 发现uid=2的账户不存在,放弃本次操作 ROLLBACK;ROLLBACK一执行,第一条UPDATE的扣款也会被撤销,数据回到事务开始前的状态。这就是原子性在起作用。
但有个东西必须提醒:在用MySQL命令行或者客户端工具时,默认是autocommit=1,也就是每条SQL自己单独成一个事务,执行完自动提交。如果你没关这个,你写“START TRANSACTION”之前的那些SQL根本不在事务保护范围内,出了事也没法回滚。
我先说结论,日常生产环境里,我几乎不会依赖客户的图形化客户端去手动提交。我的习惯是:应用程序里开启事务、捕获异常后回滚、正常结束后提交,数据库连接层用连接池。这样的好处是事务边界由代码控制,而不是靠人肉点按钮。
再看一个容易被新手忽略的细节:事务中如果出现错误,MySQL某些版本并不自动回滚,只是标记这条语句失败,后续SQL还能继续执行。所以代码里捕获到异常时,必须显式调用ROLLBACK。我见过线上事故,项目组以为事务里SQL报错就会自动回滚,结果半截数据留在表里,账单对不平,最后开发手动补数据,那叫一个痛。
2.3 隔离级别:并发场景下的四档选择
事务隔离性是个大话题。两个事务同时操作同一行数据,可能会遇到三种异常情况:
- 脏读:事务A读到了事务B还没提交的修改;万一事务B回滚,A读到的就是无效数据。
- 不可重复读:事务A两次读同一行,第一次读的时候事务B还没提交,第二次读的时候B已提交,A发现同一行数据变了。
- 幻读:事务A按条件查一批数据,事务B插入、删除了一些行并提交,事务A再按同样条件查,发现多了一行或者少了一行,像幻觉一样。
SQL标准定了四种隔离级别,每个级别能防住的问题不一样:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 并发性能 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 最高 |
| READ COMMITTED | 不可能 | 可能 | 可能 | 较高 |
| REPEATABLE READ | 不可能 | 不可能 | 可能(InnoDB可避免) | 中等 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 | 最低 |
MySQL InnoDB默认是REPEATABLE READ,配合间隙锁能解决大部分幻读问题。PostgreSQL默认是READ COMMITTED,实现思路又不一样。选级别没有绝对的好坏,只有合不合适:
- 报表统计、日志分析,允许读到旧点的数据,可以设READ COMMITTED甚至READ UNCOMMITTED,并发高、锁少。
- 金融交易、库存扣减,数据必须绝对准,宁愿性能损失也要用REPEATABLE READ或SERIALIZABLE。
我实际负责过一个库存系统,一开始所有事务都按默认级别跑,结果高并发秒杀场景下,两个用户同时抢最后一台库存,两个请求都读到库存为1,各自扣减后都返回成功,实际上超卖了1台。最后排查出来,是REPEATABLE READ下两个事务都持有了快照,后一个提交时并没有重新校验当前库存。解决办法是加行锁,或者在UPDATE语句里加库存条件:UPDATE product SET stock = stock - 1 WHERE id = ? AND stock > 0;,靠受影响行数来判断是否抢到。死记隔离级别没用,得理解你的业务到底对“瞬时一致性”多敏感。
另一个和隔离级别强相关的东西是锁。InnoDB的行锁、表锁、间隙锁,本质都是为了让并发事务“排队”。但锁用得太狠,事务之间互相等资源,就会死锁。死锁的典型特征是两条事务各持有一把锁不释放,同时又在等对方手里的锁,系统检测到后会回滚其中一个事务。
遇到死锁不要慌,数据库会自己选一个受害者回滚,报错码一般是1213。排查的重点是找出两个事务都更新了哪些表哪些行,看是不是有交叉的更新顺序。我之前踩过一个坑:一个事务里先更新订单表再更新用户表,另一个事务先更新用户表再更新订单表,两个并发跑起来,大概率死锁。解决办法是把所有事务里的更新顺序固定下来,先用户后订单,或者先订单后用户,统一节奏,交叉等待自然消失。另外,事务里别做慢查询、别带网络请求,锁持有时间越短,死锁概率越低,这是铁律。
3. DCL:权限管理的边界
3.1 用户与权限模型:从 root 到应用账号
前两天的课程里,可能你一直在用root账号连数据库,图省事。但真实生产环境里,root账号差不多等于“王炸”,很少直接给应用用。原因很简单:应用需要的最小权限,和root具备的权限差了十万八千里。万一代码里埋了SQL注入漏洞,攻击者拿到root,等于把你整个数据库脱裤了。
DCL(Data Control Language)就是管这个的。它核心就两个动词:GRANT授予权限,REVOKE回收权限。MySQL的权限分成好几个层级:
- 全局级:对整个MySQL实例生效,比如SUPER、PROCESS、RELOAD。
- 库级:对某个数据库生效,比如对demo_db的SELECT、INSERT、UPDATE。
- 表级:对某张表生效。
- 字段级:对某个表的某个字段生效。
先看创建用户的基本操作:
-- 创建用户,指定它只能从本机连接 CREATE USER 'app_read'@'localhost' IDENTIFIED BY 'StrongP@ss'; -- 创建用户,允许从任意主机连接(实际生产慎用) CREATE USER 'app_write'@'%' IDENTIFIED BY 'AnotherP@ss';一个常见的疑问是:为什么CREATE USER后面的@'localhost'和@'%'很重要?因为MySQL校验用户身份不是只看用户名,而是“用户名 + 客户端来源主机”的组合。'app_read'@'localhost'和'app_read'@'%'是两个完全不同的账号,可以拥有完全不同的权限。有时候你建了用户连不上,排查半天,才发现是忘了指定host,或者host写错了。
3.2 GRANT 与 REVOKE 实操
把权限给某个用户,语法其实比很多人想象的更细,可以精确到操作类型和对象。这里给一个最常用的例子:
-- 给app_read账号查询demo_db库所有表的权限 GRANT SELECT ON demo_db.* TO 'app_read'@'localhost'; -- 给app_write账号增删改查demo_db库所有表的权限 GRANT SELECT, INSERT, UPDATE, DELETE ON demo_db.* TO 'app_write'@'localhost'; -- 给管理员账号授予某个库的全部权限,包括建表、删表 GRANT ALL PRIVILEGES ON demo_db.* TO 'admin_user'@'localhost';有些人以为授权完就能立刻生效,但在某些场景下,权限变更不会立刻应用到已经存在的连接上。MySQL的权限缓存机制导致新授权一般对新建连接立即生效,已存在的连接可能需要重连,某些版本里还用FLUSH PRIVILEGES强制刷新。我的习惯是:每次GRANT或REVOKE后直接执行一次FLUSH PRIVILEGES,省得线上环境出现“为什么他还能访问”的灵异事件。
再来看回收权限:
-- 回收app_write的DELETE权限 REVOKE DELETE ON demo_db.* FROM 'app_write'@'localhost'; -- 回收所有权限 REVOKE ALL PRIVILEGES ON demo_db.* FROM 'admin_user'@'localhost';REVOKE的写法跟GRANT基本镜像,但有个细节容易踩:如果你授过“对所有库的删除权限”,但回收时只写了“对demo_db库的删除权限”,那其他库的删除权限并不会被收走。权限回收一定要精确到当初授权的范围,宁多勿漏。
如果只是想临时封禁一个账号,不一定要REVOKE,可以直接改账号状态:
-- 锁定账号 ALTER USER 'app_write'@'localhost' ACCOUNT LOCK; -- 解锁账号 ALTER USER 'app_write'@'localhost' ACCOUNT UNLOCK;这个操作比REVOKE快,适合线上应急处理。比如发现某个应用账号在刷量、跑异常任务,先锁了再说,排查完再决定是恢复还是撤权。
3.3 最小权限原则与日常巡检
DCL这一块,新手最容易犯的毛病不是不会写GRANT,而是权限给得太大方。我见过某公司给所有开发统一配了ALL PRIVILEGES,结果有人误删一张核心业务表,恢复花了两天。最小权限原则听起来像口号,落到实操上其实就几点:
- 只读需求的账号,只给SELECT;需要写数据的账号,按需给INSERT、UPDATE、DELETE,能不给DROP就不给DROP。
- 生产库的开发账号,一律不给DDL权限。建表、改表结构走专门的发布流程,由DBA执行。
- 涉及敏感信息(用户手机号、身份证号)的字段,可以单独回收字段权限,或者用视图隔离敏感列。
- 每个应用部署到不同环境,用不同的账号和密码,不要共享一套,防止一个环境泄露连累全部环境。
日常巡检定期做一件事:查一下当前实例里都有哪些用户、哪些账号有异常权限。可以用这条SQL快速看:
SELECT user, host, Grant_priv, Super_priv, Create_priv, Drop_priv FROM mysql.user;再查具体某个库上的授权:
SHOW GRANTS FOR 'app_read'@'localhost';还有一个我强烈建议的习惯:密码不要写在代码仓库里。很多事故都是有人把配置文件传到公开仓库,里面赫然写着生产库密码。正确的做法是把密码放到环境变量、密钥管理服务或配置中心里去,代码里只留占位符。应用账号的密码也要定期轮换,尤其是人员变动频繁的团队,离职员工的账号记得第一时间REVOKE或LOCK掉。
4. 三块内容怎么配合:一个模拟订单支付场景
Day04这三块内容不是孤立的。我拿一个简单的线上订单支付流程,把它们串起来讲一遍,你会发现它们各自都在守不同的防线。
假设系统里有用户表、订单表、库存表。用户下单支付时要做的事是:扣减库存、扣减用户余额、生成订单、记录流水。这几步必须在同一个事务里,否则就会出现“库存扣了但订单没生成”“金额扣了但库存没锁住”这类灾难。伪代码大概这样:
def pay_order(user_id, product_id, quantity): try: cursor.execute("START TRANSACTION") # 扣库存,并检查库存是否足够 affected = cursor.execute( "UPDATE product SET stock = stock - %s WHERE id = %s AND stock >= %s", (quantity, product_id, quantity) ) if affected == 0: raise Exception("库存不足") # 扣用户余额 affected = cursor.execute( "UPDATE account SET balance = balance - %s WHERE uid = %s AND balance >= %s", (total_amount, user_id, total_amount) ) if affected == 0: raise Exception("余额不足") # 生成订单 cursor.execute( "INSERT INTO order_info (user_id, product_id, amount, status) VALUES (%s, %s, %s, 1)", (user_id, product_id, total_amount) ) cursor.execute("COMMIT") except Exception as e: cursor.execute("ROLLBACK") raise e注意两点。第一,UPDATE语句里的“AND stock >= %s”不是装饰品,它是防止超卖的关键,通过受影响行数来判断竞争是否成功;第二,整个事务里的操作必须全部成功才COMMIT,任何一个环节异常都走ROLLBACK。这就是事务在保数据一致。
那DCL在这里有什么用?假设这个支付服务属于应用A,那么数据库账号就只给它授增删改查订单、商品、账户相关表的权限,绝对不给它DROP或TRUNCATE。就算攻击者通过接口注入拿到了这个数据库账号,他能做的也只是改业务数据,没法直接删库。多表查询在这里则是报表链路的事:支付完成之后,业绩报表要把订单表、用户表、商品表关联起来,统计每个销售员的订单金额。这个查询账号通常只授SELECT,让报表能读,但不能写。一个系统里三个账号分得清清楚楚,职责边界就干净了。
5. 常见问题与排查技巧实录
Day04的内容,知识点其实不多,但操作里全是坑。我把带新人时最常遇到的问题整理成了一张排查表,你们直接对照着看:
| 现象 | 常见原因 | 解决思路 |
|---|---|---|
| JOIN查询结果比预期多很多 | 关联条件漏写,或两张表一对多导致行数膨胀 | 先查两表行数,再抽样核对ON条件后的匹配关系 |
| LEFT JOIN没拿到预期行 | WHERE里写了右表字段,把NULL补位的行过滤了 | 把过滤条件移到ON子句,或对右表字段加 IS NULL 判断 |
| NOT IN查不到数 | 子查询结果里有NULL | 改用NOT EXISTS,或在子查询里排除NULL |
| 事务一直不提交,连接越来越多 | 代码没在finally里COMMIT/ROLLBACK,或客户端开了事务忘了关 | 查information_schema.innodb_trx,定位长事务,补齐提交逻辑 |
| 死锁频繁 | 多个事务更新多张表的顺序不一致 | 统一更新顺序,缩短事务时长,重试被回滚的事务 |
| 授权后账号连不上 | host段不匹配,或者密码策略过期 | 查看账号host,确认连接来源;检查密码策略 |
| REVOKE之后还能操作 | 授权的范围比回收的范围大,或者连接未重连 | 精确复核原授权范围,必要时FLUSH PRIVILEGES、重连 |
再分享几个我实际踩过的坑。
第一个是事务里查了慢SQL。有一次线上批量任务特别慢,把数据库连接池都占满了,所有新请求都卡在拿连接上。查下来发现是事务内部先做了一次很大的全表扫描,虽然业务上也合理,但这条慢SQL把事务的锁持有时间无限拉长,后面的写操作全被阻塞。从那以后我做了一个规定:事务里只做必要的SQL,任何耗时操作、远程调用、外部HTTP请求,一律拆到事务外边。
第二个是连接池与事务的配合问题。Java或Python里用连接池时,如果你忘记在事务结束后归还连接,连接池会一直认为这个连接在事务中,下一次从连接池拿到同一个连接的人会“继承”上一个事务的上下文,发生串数据。排查办法是给每个请求的事务加超时时间,并确保COMMIT/ROLLBACK放在finally里。
第三个是权限变更看似没生效。有次我给一个同学开了只读账号,授权语句执行成功,他连上去还是提示无权限。后来发现他在同一个客户端里用的连接是授权之前建立的,连接缓存了旧的权限状态。断开重连之后问题就消失了。所以遇到“授权不生效”,先别急着加权限,先重连再说。
第四个是DROP权限给得太随意。某次事故就是有人拿到生产库的DROP权限,清理临时表时表名写错,把核心业务表直接干没了。虽然最终通过备份恢复,但丢了近两个小时的数据,业务方急得跳脚。所以我现在对所有账号的授权都遵循同一原则:不用的权限一律不给,宁缺毋滥。
最后聊两句我对Day04这门课的感受
四天学下来,单表查询教会了你怎么和一张表对话,多表查询是让多张表在逻辑上变成一张表,事务是让你敢放手去改数据,DCL则是告诉你谁有资格来改。这四件事配合起来,才算把数据库从“能存能查”用到了“能扛业务、能防事故”的层面。
我个人在实际操作中的体会是:多表查询真正值钱的不是记住几种JOIN语法,而是UNION、子查询、EXISTS这些写法在不同数据库下的执行计划差异;事务真正值钱的不是会写START TRANSACTION,而是能说清楚你的业务处于哪个隔离级别、会遇到什么并发问题;DCL真正值钱的不是会GRANT REVOKE,而是能设计出一套符合最小权限原则的账号体系。把这三个“真正值钱”的东西弄明白,Day04就没白学。后面再往深走,无非就是执行计划优化、读写分离、分库分表,但今天这些地基打不牢,后面全是空中楼阁。