Java八股文——MySQL「SQL 基础篇」
NOSQL和SQL的区别?
面试官您好,SQL(关系型数据库)和NoSQL(非关系型数据库)是当今数据存储领域的两大主流阵营。它们之间不是“谁取代谁”的关系,而是两种完全不同的设计哲学,适用于解决不同类型的问题。
我通常会从以下几个核心维度来对比它们:
1. 数据模型 (Data Model) —— 结构化 vs. 灵活
-
SQL (关系型数据库):
- 核心:基于关系模型,数据被组织在结构化的二维表(Table)中,表有固定的模式(Schema),包含行(Row)和列(Column)。
- 特点:强Schema。在写入数据之前,必须先定义好表的结构(字段名、数据类型、约束等)。所有存入的数据都必须严格遵守这个结构。
- 例子:MySQL, PostgreSQL, Oracle, SQL Server。
-
NoSQL (非关系型数据库):
- 核心:数据模型非常多样和灵活,没有固定的模式。
- 主要类型:
- 键值存储 (Key-Value):数据以简单的
{key: value}
形式存储,非常高效。代表:Redis, Memcached。 - 文档存储 (Document):数据以类似JSON或BSON的文档格式存储,结构灵活,可以嵌套。代表:MongoDB。
- 列族存储 (Column-Family):数据按列族组织,非常适合海量数据的读写和分析。代表:HBase, Cassandra。
- 图存储 (Graph):专门用于存储和查询图结构数据(节点、边、属性),如社交网络关系。代表:Neo4j。
- 键值存储 (Key-Value):数据以简单的
- 特点:动态Schema或无Schema。可以随时向数据中添加新的字段,无需预先定义。
2. 事务与一致性 (Transaction & Consistency)
-
SQL:
- 核心:遵循ACID原则(原子性、一致性、隔离性、持久性)。
- 特点:提供非常强大的事务支持,能够保证数据的强一致性。非常适合对数据准确性要求极高的场景。
-
NoSQL:
- 核心:通常遵循BASE理论(基本可用、软状态、最终一致性)。
- 特点:为了追求更高的性能和可用性,通常会牺牲强一致性,转而支持最终一致性。它们对事务的支持通常较弱,或者只支持单行/单文档的原子操作。
3. 扩展性 (Scalability)
-
SQL:
- 扩展方式:通常通过垂直扩展(Scale-up) 来实现,即提升单个服务器的硬件性能(更强的CPU、更大的内存、更快的硬盘)。
- 水平扩展(Scale-out):虽然也可以通过分库分表、读写分离等方式实现水平扩展,但实现起来相对复杂,且对应用层有侵入。
-
NoSQL:
- 扩展方式:天生为分布式和水平扩展而设计。
- 特点:可以非常容易地通过增加更多的普通服务器节点来线性地提升整个集群的性能和存储容量。它们的架构通常内置了数据分片(Sharding)和副本(Replication)机制。
4. 查询语言 (Query Language)
-
SQL:
- 使用标准化的SQL(Structured Query Language) 进行查询。SQL功能强大,支持复杂的多表连接(JOIN)、聚合、分组等操作。
-
NoSQL:
- 没有统一的查询语言。每种NoSQL数据库都有自己独特的查询API或查询语言。它们的查询能力通常不如SQL强大,特别是不擅长处理复杂的多表连接查询。
总结与选型建议
特性 | SQL (关系型) | NoSQL (非关系型) |
---|---|---|
数据模型 | 结构化 (二维表, 强Schema) | 多样化 (Key-Value, 文档等, 弱/无Schema) |
一致性 | ACID (强一致性) | BASE (最终一致性) |
扩展性 | 垂直扩展 (Scale-up) 为主 | 水平扩展 (Scale-out) 为主 |
事务 | 强大 | 弱或不支持 |
适用场景 | 事务性强的、数据关系复杂的应用 | 高并发、海量数据、结构不固定的应用 |
我的选型策略:
-
什么时候选择SQL数据库?
- 当业务需要强大的事务保证时,比如金融、电商的订单和支付系统。
- 当数据之间存在复杂的关系,需要进行频繁的连接查询时。
- 当数据结构相对稳定,不需要频繁变更时。
-
什么时候选择NoSQL数据库?
- 当需要极高的读写性能和海量数据存储时,比如社交网络的信息流、物联网的传感器数据。
- 当数据结构不固定、经常需要变化时,比如用户画像、商品属性等。
- 当系统需要极高的可用性和易于水平扩展时。
- 作为关系型数据库的补充,比如用Redis来做MySQL的热点数据缓存。
在现代架构中,我们通常不会只选择一种,而是将SQL和NoSQL数据库组合使用,让它们各自在最擅长的领域发挥作用,以构建出更健壮、性能更高的系统。
数据库三大范式是什么?
面试官您好,数据库的三大范式(Normal Forms, NF)是我们在进行关系型数据库逻辑设计时,所遵循的一套基本准则和规范。
它的核心目标,是通过对表结构的合理设计,来减少数据冗余、避免数据异常(如插入异常、更新异常、删除异常),从而保证数据的一致性和完整性。
我来分别解释一下这三大范式,并用一个例子来贯穿。
假设我们有一个未优化的“订单信息表”:
原始表:订单表 (Order_Info)
订单ID | 顾客姓名 | 顾客电话 | 商品ID | 商品名称 | 商品单价 | 购买数量 |
---|---|---|---|---|---|---|
O001 | 张三 | 138… | P01 | 手机 | 5000 | 1 |
O001 | 张三 | 138… | P02 | 耳机 | 200 | 2 |
O002 | 李四 | 139… | P01 | 手机 | 5000 | 1 |
第一范式 (1NF): 保证字段的原子性
- 定义:要求数据库表中的每一个字段(列)都是不可再分的原子值。
- 核心思想:确保“一格一值”,不能在一个单元格里存储多个值。
- 如何判断:我们看上面的原始表,每个字段都只包含一个单一的数据,没有像“手机,耳机”这样存储在一个单元格里的情况。所以,这个原始表已经满足了第一范式。
- 反例:如果我们设计一个
商品
字段,里面存的是"P01:手机:5000, P02:耳机:200"
,这就违反了1NF。
第二范式 (2NF): 消除对主键的部分函数依赖
- 定义:在满足第一范式的基础上,要求表中的每一个非主键字段,都必须完全依赖于整个主键,而不是只依赖于主键的一部分。
- 核心思想:确保“有事儿找老大,别找副手”。这个范式只针对联合主键的情况。如果表是单主键,那么它只要满足1NF,就自动满足2NF。
- 如何分析我们的例子:
- 在原始表中,要唯一确定一条记录(比如张三买手机这条),需要 (订单ID, 商品ID) 作为联合主键。
- 我们来分析非主键字段:
购买数量
:它既依赖于订单ID
,也依赖于商品ID
,是完全依赖。顾客姓名
,顾客电话
:它们只依赖于订单ID
,与商品ID
无关。这是部分依赖。商品名称
,商品单价
:它们只依赖于商品ID
,与订单ID
无关。这也是部分依赖。
- 结论:存在部分依赖,所以不满足第二范式。
- 如何改造(拆分):
- 将部分依赖的字段拆分出去,形成新的表。
- 订单表 (Orders): (主键:
订单ID
)订单ID 顾客姓名 顾客电话 O001 张三 138… O002 李四 139… - 商品表 (Products): (主键:
商品ID
)商品ID 商品名称 商品单价 P01 手机 5000 P02 耳机 200 - 订单详情表 (Order_Details): (联合主键:
订单ID
,商品ID
)订单ID 商品ID 购买数量 O001 P01 1 O001 P02 2 O002 P01 1
- 好处:现在,
顾客姓名
和商品名称
等信息不再冗余存储,更新时也不会出现数据不一致的问题。
第三范式 (3NF): 消除对主键的传递函数依赖
-
定义:在满足第二范式的基础上,要求表中的任何非主键字段,都不能依赖于其他非主键字段。
-
核心思想:确保所有非主键字段都直接依赖于主键,而不是通过“跳板”间接依赖。
-
如何分析我们的例子:
- 我们看改造后的订单表 (Orders)。
顾客姓名
和顾客电话
是直接依赖于主键订单ID
吗? - 不完全是。更准确地说,
订单ID
决定了是哪个顾客,而顾客才决定了他的姓名和电话。这里存在一个传递依赖:订单ID -> 顾客 -> (顾客姓名, 顾客电话)
。 - 结论:存在传递依赖,所以不满足第三范式。
- 我们看改造后的订单表 (Orders)。
-
如何改造(再次拆分):
- 将传递依赖的字段也拆分出去。
- 顾客表 (Customers): (主键:
顾客ID
)顾客ID 顾客姓名 顾客电话 C01 张三 138… C02 李四 139… - 最终的订单表 (Orders): (主键:
订单ID
)订单ID 顾客ID (外键) O001 C01 O002 C02
-
好处:现在,顾客的信息是独立维护的,如果一个顾客改了电话,我们只需要修改
Customers
表的一行,所有与他相关的订单信息都能保持一致。
总结与实践
- 1NF -> 2NF:解决部分依赖,要求每个非主键字段完全依赖于整个主键。
- 2NF -> 3NF:解决传递依赖,要求每个非主键字段直接依赖于主键。
在实际的数据库设计中,我们通常会力求满足第三范式(3NF),这能最大程度地减少数据冗余,保证数据一致性。但有时,为了查询性能,我们也会进行 “反范式化” 设计,适度地增加一些冗余字段来避免复杂的多表连接(JOIN)查询,这是一种在“数据一致性”和“查询效率”之间的权衡。
MySQL 怎么连表查询?
面试官您好,在MySQL(以及大多数关系型数据库)中,当我们需要从多个关联的表中获取数据时,就需要使用连接查询(JOIN)。
最核心的连接查询主要有以下几种。我们可以用两张简单的示例表来直观地理解它们的区别:
students
表 (学生表)
id | name | class_id |
---|---|---|
1 | 张三 | 101 |
2 | 李四 | 102 |
3 | 王五 | 103 |
classes
表 (班级表)
id | name |
---|---|
101 | 一班 |
102 | 二班 |
104 | 四班 |
1. 内连接 (INNER JOIN) —— “取交集”
- 定义:这是最常用的一种连接。它只返回两个表中连接字段能够匹配上的行。可以理解为取两个表的交集。
- SQL示例:
SELECT s.name AS student_name, c.name AS class_name FROM students s INNER JOIN classes c ON s.class_id = c.id;
- 结果:
student_name class_name 张三 一班 李四 二班 - (王五因为
class_id=103
在classes
表中找不到匹配,被排除;四班因为在students
表中没有学生关联,也被排除。)
- (王五因为
2. 左外连接 (LEFT JOIN / LEFT OUTER JOIN) —— “左表为王”
- 定义:它会返回左表(
FROM
子句后的第一个表)的所有行,即使在右表中没有匹配的记录。 - 工作机制:以左表为基础,去右表中查找匹配的行。
- 如果找到了匹配行,就将右表的字段合并进来。
- 如果没找到匹配行,右表的所有字段将显示为
NULL
。
- SQL示例:
SELECT s.name AS student_name, c.name AS class_name FROM students s LEFT JOIN classes c ON s.class_id = c.id;
- 结果:
student_name class_name 张三 一班 李四 二班 王五 NULL
3. 右外连接 (RIGHT JOIN / RIGHT OUTER JOIN) —— “右表为王”
- 定义:与左连接相反,它会返回右表(
JOIN
子句后的表)的所有行,即使在左表中没有匹配的记录。 - 工作机制:以右表为基础,去左表中查找。如果左表没找到匹配,则左表的字段显示为
NULL
。 - SQL示例:
SELECT s.name AS student_name, c.name AS class_name FROM students s RIGHT JOIN classes c ON s.class_id = c.id;
- 结果:
student_name class_name 张三 一班 李四 二班 NULL 四班
4. 全外连接 (FULL OUTER JOIN) —— “合二为一,宁缺毋滥”
- 定义:它会返回两个表中的所有行。如果某一行在另一个表中没有匹配,那么另一个表的字段将显示为
NULL
。可以看作是左连接和右连接结果的并集。 - 注意:MySQL本身不直接支持
FULL OUTER JOIN
关键字。但我们可以通过LEFT JOIN
UNIONRIGHT JOIN
来模拟实现。 - 模拟SQL示例:
SELECT s.name AS student_name, c.name AS class_name FROM students s LEFT JOIN classes c ON s.class_id = c.id UNION SELECT s.name AS student_name, c.name AS class_name FROM students s RIGHT JOIN classes c ON s.class_id = c.id;
- 结果:
student_name class_name 张三 一班 李四 二班 王五 NULL NULL 四班
其他连接类型
- 交叉连接 (CROSS JOIN):返回两个表的笛卡尔积,即第一个表的每一行都与第二个表的每一行进行组合。这在没有
ON
条件时,是INNER JOIN
的默认行为,通常需要谨慎使用。 - 自连接 (SELF JOIN):指一个表与它自己进行连接。这在处理表内具有层级关系(如员工与经理、区域的父子关系)的数据时非常有用。
总结一下,在选择连接方式时,我主要考虑:
- 是否只需要两个表中都能匹配上的数据?-> 用
INNER JOIN
。 - 是否需要保留一个表的所有数据,而不管另一个表有没有匹配?-> 用
LEFT JOIN
或RIGHT JOIN
。 - 是否需要两个表的所有数据都展示出来?-> 用
FULL OUTER JOIN
(在MySQL中用UNION模拟)。
MySQL如何避免重复插入数据?
面试官您好,在MySQL中避免重复插入数据,是一个保证数据唯一性和完整性的核心问题。我会从数据库表结构层面(治本) 和SQL语句层面(治标) 这两个维度来设计解决方案。
方案一:数据库表结构层面 —— 建立唯一性约束 (治本之策)
这是最根本、最可靠的解决方案,它利用数据库自身的能力来强制保证数据的唯一性。
-
设置主键 (PRIMARY KEY)
- 作用:主键本身就具有唯一且非空的特性。如果我们的重复数据是基于某个ID字段来判断的,那么将这个ID字段设为主键,就是最直接的办法。任何试图插入重复主键值的操作,都会被数据库直接拒绝,并返回一个错误。
- 缺点:一张表只能有一个主键。
-
建立唯一索引 (UNIQUE INDEX / UNIQUE KEY)
- 作用:这是最常用、最灵活的方式。我们可以为一个或多个字段的组合,创建一个唯一索引。
- 单字段唯一:比如,在用户表中,
username
或email
字段必须是唯一的。-- 创建表时定义 CREATE TABLE users (id INT PRIMARY KEY AUTO_INCREMENT,username VARCHAR(50) NOT NULL,email VARCHAR(100),UNIQUE KEY `uk_username` (`username`),UNIQUE KEY `uk_email` (`email`) );-- 或为已存在的表添加 ALTER TABLE users ADD UNIQUE INDEX `uk_username` (`username`);
- 多字段联合唯一:比如,在一个“用户-角色”关联表中,一个用户只能拥有一个特定的角色一次。
(user_id, role_id)
这个组合必须是唯一的。CREATE TABLE user_roles (user_id INT,role_id INT,UNIQUE KEY `uk_user_role` (`user_id`, `role_id`) );
- 优点:
- 绝对可靠:由数据库层面保证,无论应用逻辑如何,重复数据都无法插入。
- 性能高:唯一索引本身也能加速对这些字段的查询。
方案二:SQL语句层面 —— 在插入时进行判断和处理
有时候,我们不希望插入重复数据时直接抛出异常,而是希望有一些更优雅的处理方式,比如“如果存在就更新,不存在就插入”。这时,我们可以使用特定的SQL语法。
-
INSERT IGNORE INTO
- 作用:当执行
INSERT
操作时,如果因为唯一键冲突(主键或唯一索引)而导致插入失败,这条INSERT
语句会被默默地忽略掉,不会产生任何错误。 - SQL示例:
INSERT IGNORE INTO users (username, email) VALUES ('admin', 'admin@example.com');
- 适用场景:只关心“数据必须存在”,不关心是新插入的还是已存在的,并且不希望程序因为重复插入而中断。
- 作用:当执行
-
REPLACE INTO
- 作用:这是一个比较“暴力”的方式。当执行
REPLACE
操作时,如果唯一键冲突,它会先删除那条旧的记录,然后再插入一条新的记录。 - SQL示例:
REPLACE INTO users (id, username, email) VALUES (1, 'admin_new', 'admin_new@example.com');
- 警告:这个操作的本质是
DELETE
+INSERT
,如果表上有触发器,会先后触发删除和插入的触发器。并且,如果id
是自增的,它会消耗一个新的ID,而不是更新旧的。需要非常谨慎使用。
- 作用:这是一个比较“暴力”的方式。当执行
-
INSERT INTO ... ON DUPLICATE KEY UPDATE
(推荐)- 作用:这是最常用、最灵活、最推荐的“存在即更新”方案。
- 工作机制:当执行
INSERT
操作时,如果发生唯一键冲突,它不会报错,而是会转而去执行UPDATE
子句中指定的更新逻辑。 - SQL示例:假设我们想在用户登录时,更新其最后登录时间,如果用户不存在就创建。
INSERT INTO users (username, last_login_time) VALUES ('admin', NOW()) ON DUPLICATE KEY UPDATE last_login_time = NOW();
- 优点:一条SQL就优雅地实现了“Insert or Update”(也叫
Upsert
)的逻辑,非常高效和方便。
总结与最佳实践
- 第一道防线(必须有):在数据库表上建立唯一键约束(主键或唯一索引)。这是保证数据完整性的根本,不能依赖应用层的逻辑。
- 第二道防线(按需选择):在应用代码中,根据业务需求选择合适的SQL处理方式:
- 希望存在就忽略,不存在就插入 -> 使用
INSERT IGNORE
。 - 希望存在就更新,不存在就插入 -> 强烈推荐使用
ON DUPLICATE KEY UPDATE
。 - 在写操作之前,先执行一次
SELECT
来判断数据是否存在。这在并发量低时可行,但在高并发下,从SELECT
到INSERT
之间存在时间窗口,可能导致竞态条件,不推荐。
- 希望存在就忽略,不存在就插入 -> 使用
通过这两层防线的结合,我们就能非常健壮地处理数据重复插入的问题。
CHAR和 VARCHAR有什么区别?
面试官您好,CHAR
和VARCHAR
是MySQL中最常用的两种字符串类型,它们最核心的区别在于其长度的存储和处理方式,这个区别直接导致了它们在存储空间、性能和适用场景上的巨大差异。
1. 核心区别:定长 vs. 变长
-
CHAR(N)
(定长)- 定义:
CHAR
是一种固定长度的字符串类型。当我们定义一个CHAR(10)
的字段时,无论我们实际存入的数据是"abc"
(3个字符)还是"hello"
(5个字符),它在数据库中永远都会占用10个字符的存储空间。 - 存储机制:如果存入的数据长度小于
N
,MySQL会在其右侧用空格进行填充,以补足到指定的长度。在读取时,这些尾部的空格通常会被自动去除(除非SQL_MODE有特殊设置)。
- 定义:
-
VARCHAR(N)
(变长)- 定义:
VARCHAR
是一种可变长度的字符串类型。N
在这里代表的是最大长度。 - 存储机制:它只会根据实际存入的数据长度来分配存储空间。但除了数据本身,它还需要额外的1到2个字节来记录其实际长度。
- 如果最大长度
N
小于等于255,用1个字节记录长度。 - 如果最大长度
N
大于255,用2个字节记录长度。
- 如果最大长度
- 定义:
2. 存储空间与性能的权衡
特性 | CHAR(N) | VARCHAR(N) |
---|---|---|
空间开销 | 固定,可能浪费空间 | 可变,通常更节省空间 |
更新效率 | 更高、更稳定 | 可能导致性能问题 |
碎片化 | 不易产生 | 容易产生 |
-
空间对比:
- 当存储的字符串长度非常接近甚至就等于
N
时,CHAR
可能比VARCHAR
更节省空间,因为它省去了那1-2个字节的长度记录开销。 - 当存储的字符串长度差异很大时,
VARCHAR
的优势就非常明显了,能极大地节约磁盘空间。
- 当存储的字符串长度非常接近甚至就等于
-
性能对比(这是一个更深入的考量点):
CHAR
的优势:因为长度固定,CHAR
类型的字段在进行更新(UPDATE)操作时,通常不会改变记录的物理长度。这使得数据库在原地更新数据变得非常容易,不容易导致行迁移(Row Migration)或页分裂(Page Split),因此更新性能更稳定。VARCHAR
的劣势:如果一个VARCHAR
字段的值从一个短字符串更新为一个长字符串(比如从"hi"
更新为"hello world"
),导致该行的总长度超出了当前数据页的剩余空间,就可能触发代价高昂的页分裂操作,从而影响性能。
3. 选型建议与最佳实践
基于以上对比,我的选型策略非常明确:
-
什么时候选择
CHAR
?- 当字段的长度是固定不变或几乎不变的时候。这是
CHAR
最理想的场景。- 典型例子:MD5哈希值(固定32位)、UUID(固定36位)、性别(‘M’/‘F’)、邮政编码、身份证号等。
- 当存储的字符串非常短时(比如1-10个字符),即使长度可变,使用
CHAR
的性能和空间优势也可能超过VARCHAR
。
- 当字段的长度是固定不变或几乎不变的时候。这是
-
什么时候选择
VARCHAR
?- 绝大多数场景下,当字段的长度是可变的,都应该使用
VARCHAR
。- 典型例子:用户名、商品标题、文章内容、备注信息等。
- 在使用
VARCHAR
时,一个重要的最佳实践是为N
设置一个合理的、尽可能小的最大长度。比如,一个用户名字段,设置成VARCHAR(50)
就比VARCHAR(255)
要好得多。这不仅能节省空间,还能利用到MySQL的内存优化(比如在排序时可以使用内存临时表)。
- 绝大多数场景下,当字段的长度是可变的,都应该使用
总结一下,CHAR
追求的是处理速度和性能的稳定性,以可能浪费空间为代价;而VARCHAR
追求的是空间的极致利用,以可能在更新时产生一些性能开销为代价。在实际设计中,我们需要根据数据的具体特性来做出最合适的选择。
VARCHAR后面代表字节还是字符?
面试官您好,这是一个非常好的细节问题,也是很多开发者容易混淆的地方。
在现代的MySQL版本(5.0及以后)中,VARCHAR(N)
括号里的数字N
,明确代表的是“字符数”(Character Count)。
1. 核心概念:字符 vs. 字节
- 字符:是我们人类语言中最小的意义单位,比如一个英文字母
'A'
、一个数字'1'
、或者一个汉字'中'
,都算作一个字符。 - 字节:是计算机中存储数据的基本单位。一个字符究竟占用多少个字节,完全取决于数据库所使用的字符集(Charset)。
2. 字符集的影响
正如您所说,不同的字符集,每个字符占用的字节数是不同的:
-
latin1
或ascii
字符集:- 每个字符(主要是英文字母、数字、标点)都只占用1个字节。
- 在这种情况下,
VARCHAR(10)
最多能存10个字符,最大占用10个字节。
-
gbk
字符集:- 一个英文字母占用1个字节,一个汉字占用2个字节。
VARCHAR(10)
仍然能存10个字符,比如10个汉字,此时它会占用10 * 2 = 20
个字节。
-
utf8mb4
字符集 (现在最推荐的通用字符集):- 这是一个可变长度的字符集。一个英文字母占用1个字节,一个常用汉字占用3个字节,而一些生僻字或Emoji表情可能占用4个字节。
- 对于
VARCHAR(10)
,它可以存:- 10个英文字母(占用10字节)。
- 10个常用汉字(占用30字节)。
- 10个Emoji表情(占用40字节)。
- 或者它们的任意组合,只要总字符数不超过10。
3. 存储开销的完整计算
VARCHAR
的总物理存储开销,等于真实数据的字节数,再加上1到2个字节用于记录长度的“前缀”。
- 总开销 = (真实数据的字节数) + (1或2字节的长度前缀)
4. 历史演进与一个重要的“边界”
- 历史演进:值得一提的是,在非常古老的MySQL版本(4.1之前),
VARCHAR(N)
中的N
确实指的是字节数。但现在我们使用的版本,都已经统一为字符数了。 - 边界限制:
VARCHAR
的N
虽然理论上最大可以设置到65535,但实际上它会受到MySQL单行最大长度(65535字节) 的限制。- 比如,在一张
utf8mb4
编码的表中,由于一个字符最多可能占用4个字节,所以你最多只能定义一个VARCHAR(16383)
左右的字段(16383 * 4
约等于65532),因为还要给其他字段和一些内部开销留出空间。
- 比如,在一张
总结一下,VARCHAR(N)
中的N
是字符数,这是一个非常人性化的设计,因为它让我们在定义字段时,可以更专注于业务含义(比如“用户名最多20个字”),而不需要去过多地关心底层不同字符集导致的字节换算问题。但我们在设置N
的大小时,也需要对字符集有一个基本的了解,以便估算其可能占用的最大物理空间。
INT(1) 和 INT(10) 在MySQL中有什么不同?
面试官您好,INT(1)
和INT(10)
的区别,是MySQL中一个极其常见、但又极其容易被误解的知识点。
最核心、最直接的结论是:在存储和计算方面,INT(1)
和 INT(10)
没有任何区别。
1. 破除误解:括号里的数字不是长度限制
很多初学者会误以为INT(1)
只能存1位数的整数,INT(10)
能存10位数的整数。这是完全错误的。
- 存储空间固定:在MySQL中,
INT
这个数据类型,无论你怎么写,它在磁盘上占用的存储空间永远是固定的4个字节。 - 存储范围固定:因此,它的存储范围也永远是固定的。对于有符号的
INT
,范围是-2147483648
到2147483647
;对于无符号的UNSIGNED INT
,范围是0
到4294967295
。 - 结论:无论你定义的是
INT(1)
还是INT(10)
,你都可以往里面存入12345
这样的数字,只要它在INT
的范围内。
2. 唯一的区别:显示宽度 (Display Width)
那么,括号里的这个数字到底是什么意思呢?
- 正如您所说,它仅仅是一个 “显示宽度” 的提示。这个概念是从早期的命令行客户端继承下来的,用于告诉客户端在显示这个字段的查询结果时,应该预留多少个字符的宽度。
3. 唯一生效的场景:配合 ZEROFILL
使用
在现代的MySQL客户端和各种编程语言的驱动中,这个“显示宽度”提示基本上已经被完全忽略了。它唯一还能产生可见效果的场景,就是当这个字段同时被设置了 ZEROFILL
属性时。
ZEROFILL
的作用:它会自动地用前导零,来填充数字,使其达到指定的“显示宽度”。- 举例说明:
假设我们有一个字段id
,类型是INT(5) ZEROFILL
。- 如果我们存入的值是
123
,那么查询出来显示时,就会变成00123
。 - 如果我们存入的值是
123456
(超过了显示宽度),它不会被截断,查询出来仍然是123456
。ZEROFILL
只负责补零,不负责截断。
- 如果我们存入的值是
- 注意:一旦为字段设置了
ZEROFILL
,该字段会自动变为UNSIGNED
(无符号) 。
4. 现代开发的最佳实践
- 为什么这个特性现在几乎无用了?
- 因为数据的展示格式,现在几乎完全是由应用程序的后端或前端代码来控制的,而不是依赖于数据库的
ZEROFILL
这种底层特性。比如,我们需要一个5位数的订单号,我们会在Java代码里用String.format("%05d", orderId)
来实现,而不是在数据库里。
- 因为数据的展示格式,现在几乎完全是由应用程序的后端或前端代码来控制的,而不是依赖于数据库的
- 我的实践建议:
- 在创建表时,直接使用
INT
或BIGINT
即可,完全不需要在后面加括号和数字。比如:CREATE TABLE my_table (id INT,user_id BIGINT );
- 这样做,代码更简洁,也避免了给其他开发者带来不必要的困惑。
- 在创建表时,直接使用
总结一下,INT(1)
和INT(10)
在功能和存储上完全一样。括号里的数字是一个历史遗留的、只在配合ZEROFILL
时才生效的“显示宽度”属性,在现代应用开发中,我们应该直接忽略它,使用不带括号的INT
。
TEXT数据类型可以无限大吗?
面试官您好,这是一个很好的问题,也是一个常见的误区。答案是:TEXT
数据类型并不是无限大的,它有明确的长度限制。
MySQL为了满足不同长度文本的存储需求,提供了TEXT
类型的一个“家族”,正如您所列举的,主要有以下几种:
类型 | 最大长度 (字节数) | 约等于 | 长度记录开销 |
---|---|---|---|
TINYTEXT | 255 (2^8 - 1) | 255 B | 1字节 |
TEXT | 65,535 (2^16 - 1) | 64 KB | 2字节 |
MEDIUMTEXT | 16,777,215 (2^24 - 1) | 16 MB | 3字节 |
LONGTEXT | 4,294,967,295 (2^32 - 1) | 4 GB | 4字节 |
TEXT
与 VARCHAR
的核心区别
在选择存储长文本时,我们经常会在TEXT
和VARCHAR
之间犹豫。它们有几个本质的区别:
-
行内存储 vs. 行外存储:
VARCHAR
:在MySQL中,VARCHAR
的数据通常是存储在数据行内部的(除非行总长度超过了限制)。TEXT
:为了不让单行数据过大,TEXT
类型的数据通常是存储在行外的专用存储空间中,而在数据行内部,只保留一个指向这块外部空间的指针。
-
默认值:
VARCHAR
字段可以有默认值(DEFAULT
)。TEXT
(以及BLOB
)字段不能有默认值。
-
索引:
VARCHAR
字段可以被直接创建完整索引。TEXT
字段因为可能非常大,不能直接创建完整索引。如果需要索引,必须指定一个前缀长度,比如INDEX(content(255))
,只对内容的前255个字符创建索引。
使用TEXT
类型的注意事项与最佳实践
正是因为TEXT
类型的这些底层特性,我们在使用它时需要特别注意:
-
性能开销:由于数据可能存储在行外,每次读取
TEXT
字段,都可能需要一次额外的磁盘I/O(去获取指针指向的数据),这会比直接读取行内的VARCHAR
性能要差。因此,在查询时,应该避免不必要地SELECT *
,只在确实需要时才查询TEXT
字段。 -
排序与分组:对
TEXT
字段进行ORDER BY
或GROUP BY
操作,性能会非常低下,因为它可能需要在磁盘上创建巨大的临时表。应尽量避免这种操作。 -
内存使用:如果在查询中涉及到对
TEXT
字段的排序或连接,MySQL可能会在内存中分配大量的临时空间(tmp_table_size
和max_heap_table_size
),容易导致内存问题。
选型建议
VARCHAR
优先原则:如果能够预估出文本的最大长度,并且这个长度在MySQL的行长度限制内(通常几千个字符内),总是优先选择VARCHAR
。比如,文章标题、用户简介等,用VARCHAR(255)
或VARCHAR(1000)
就足够了。VARCHAR
的性能通常更好。- 什么时候用
TEXT
? 只有当需要存储的文本长度非常不确定,且可能非常大(超过VARCHAR
的最大限制,或者几十KB以上)时,才应该选择TEXT
类型。- 典型场景:用户发表的文章正文、商品详情的长描述、存储的JSON或XML文档等。
- 在
TEXT
家族中,也应该按需选择最小的类型。比如,如果确认内容不会超过64KB,就用TEXT
,而不是MEDIUMTEXT
或LONGTEXT
,因为更小的类型,其指针和长度记录的开销也更小。
总结一下,TEXT
不是无限大的,它是一个为了存储超长文本而设计的“重型武器”。在使用它时,我们必须意识到它带来的性能开销,并遵循“按需查询、避免排序”的最佳实践。
IP地址如何在数据库里存储?
面试官您好,在数据库中存储IP地址,主要有两种主流的方法:使用字符串类型(如VARCHAR
)和使用整型(如INT
或BIGINT
)。
这两种方法各有优劣,但在追求性能和存储效率的场景下,将IP地址转换为整型来存储,是更优的、也是业界推荐的最佳实践。
方案一:使用字符串 (VARCHAR
) 存储
这是最直观、最简单的方式。
-
如何做:直接在表中创建一个
VARCHAR(15)
(对于IPv4)或VARCHAR(39)
(对于IPv6)的字段来存储点分十进制格式的IP地址字符串,如"192.168.1.1"
。CREATE TABLE access_logs (ip_address VARCHAR(15) NOT NULL,... );
-
优点:
- 可读性极高:在数据库中直接查询时,看到的就是我们熟悉的IP地址格式,非常直观,便于调试和人工查看。
- 实现简单:应用程序无需任何转换,直接将获取到的IP字符串存入即可。
-
缺点:
- 存储空间占用大:存储一个IPv4地址,即使是
"1.1.1.1"
(7个字符),也比整型占用的空间大。最长需要15个字节。 - 查询和比较效率低:基于字符串的比较、排序和范围查询,其效率远低于基于整数的数值运算。比如,要查询一个IP段,需要使用
LIKE
或者复杂的字符串函数,无法利用索引进行高效的范围扫描。
- 存储空间占用大:存储一个IPv4地址,即使是
方案二:使用整型 (INT
或 BIGINT
) 存储 (推荐)
这是更专业、性能更好的方式。
-
核心思想:IP地址本质上是一个32位(IPv4)或128位(IPv6)的无符号整数。我们可以利用数据库的函数,将其与整数形式进行相互转换。
-
对于IPv4:
- 如何做:使用
INT UNSIGNED
(4字节无符号整型)来存储。- 转换函数:MySQL提供了两个非常方便的函数:
INET_ATON('ip_address')
: 将点分十进制的IP字符串,转换为一个32位无符号整数。INET_NTOA(integer_ip)
: 将整数形式的IP,转换回点分十进制的字符串。
- 转换函数:MySQL提供了两个非常方便的函数:
- SQL示例:
-- 创建表 CREATE TABLE access_logs_int (ip_address INT UNSIGNED NOT NULL,... );-- 插入数据 INSERT INTO access_logs_int (ip_address) VALUES (INET_ATON('192.168.1.1'));-- 查询数据并转换回字符串显示 SELECT INET_NTOA(ip_address) FROM access_logs_int WHERE ip_address = INET_ATON('192.168.1.1');
- 如何做:使用
-
对于IPv6:
- 如何做:IPv6是128位的,需要使用
VARBINARY(16)
来存储其二进制形式(BIGINT
只有8字节,不够用)。 - 转换函数:MySQL同样提供了对应的函数:
INET6_ATON('ipv6_address')
INET6_NTOA(binary_ip)
- 如何做:IPv6是128位的,需要使用
-
优点:
- 存储空间小:
INT UNSIGNED
只需要4个字节,相比VARCHAR(15)
,空间占用大大减少。 - 查询效率极高:可以非常高效地进行数值比较、排序和范围查询。比如,要查询一个IP段(从
start_ip
到end_ip
),只需要用BETWEEN
操作即可:
这种查询可以完美地利用索引,性能极佳。SELECT ... FROM access_logs_int WHERE ip_address BETWEEN INET_ATON('192.168.0.0') AND INET_ATON('192.168.255.255');
- 存储空间小:
总结与选型
对比维度 | VARCHAR | INT UNSIGNED |
---|---|---|
存储空间 (IPv4) | 7 ~ 15 字节 | 4 字节 (固定) |
可读性 | 高 | 低 (需要函数转换) |
查询/比较效率 | 低 | 高 |
范围查询 | 复杂,低效 | 简单,高效 (BETWEEN ) |
我的选型建议:
- 对于那些对性能和存储要求不高、且需要频繁人工查看的少量数据场景(比如后台管理的黑白名单配置),使用
VARCHAR
是简单可行的。 - 但对于所有大批量的、需要进行高效查询和分析的场景,比如用户访问日志、安全审计日志、IP地理位置库等,毫无疑问应该选择使用
INT UNSIGNED
(或VARBINARY(16)
for IPv6)来存储。这是一种用“可读性”换取“巨大性能和存储优势”的专业做法,是业界的最佳实践。
说一下外键约束
面试官您好,外键(Foreign Key)约束是关系型数据库中一个非常重要的概念,它的核心作用是在两个表之间建立一种强制性的关联关系,以保证数据的引用完整性(Referential Integrity)。
1. 一个生动的比喻:订单与顾客
我们可以用一个简单的例子来理解它:一个订单表 (orders
)和一个顾客表 (customers
)。
- 常理上,每一笔订单都必须属于一个真实存在的顾客。我们不希望数据库里出现一笔“无主”的、找不到顾客的订单。
- 外键约束就是用来在数据库层面,强制执行这个业务规则的。
2. 外键是如何工作的?
-
定义:我们会在“从表”(
orders
表)中创建一个字段,比如customer_id
。然后,为这个customer_id
字段添加一个外键约束,让它引用“主表”(customers
表)的主键(id
)。-- 主表:顾客表 CREATE TABLE customers (id INT PRIMARY KEY,name VARCHAR(100) );-- 从表:订单表 CREATE TABLE orders (order_id INT PRIMARY KEY,order_date DATE,customer_id INT, -- 这个字段将作为外键-- 定义外键约束FOREIGN KEY (customer_id) REFERENCES customers(id) );
-
强制的约束行为:一旦这个外键关系建立,数据库就会像一个“严格的门卫”, 执行一系列规则:
- 插入/更新时的约束 (在从表上):当你试图在
orders
表中插入或更新一条记录时,数据库会检查你提供的customer_id
,是否在customers
表的主键id
中真实存在。- 如果存在,操作成功。
- 如果不存在(比如你想创建一个属于一个不存在的顾客的订单),数据库会拒绝这次操作,并抛出一个外键约束失败的错误。
- 删除/更新时的约束 (在主表上):当你试图从
customers
表中删除一个顾客,或者修改他的id
时,数据库会检查orders
表中是否还有订单在引用这个顾客。- 如果有关联的订单存在,那么直接的删除或更新操作会失败,以防止产生“孤儿订单”。
- 插入/更新时的约束 (在从表上):当你试图在
3. 外键的级联操作 (ON DELETE / ON UPDATE)
为了更灵活地处理主表记录被删除或更新时的情况,外键约束还提供了几种级联操作策略:
ON DELETE CASCADE
: 这是最常用的级联删除。当主表中的一条记录被删除时(比如删除了一个顾客),所有从表中引用该记录的行(该顾客的所有订单)也会自动地被一并删除。ON DELETE SET NULL
: 当主表记录被删除时,从表中对应行的外键字段会被自动设置为NULL
。这要求该外键字段必须允许为NULL
。ON DELETE RESTRICT
/ON DELETE NO ACTION
: 这是默认的行为,即如果存在子表记录,就禁止删除主表记录。
ON UPDATE
也有类似的级联操作。
4. 外键的优点与缺点(实践中的权衡)
-
优点:
- 数据完整性的终极保障:由数据库层面来保证数据的一致性,这是最可靠的,比完全依赖应用层的逻辑校验要健壮得多。
- 清晰地表达数据关系:外键清晰地定义了表与表之间的依赖关系,使得数据库结构更易于理解。
-
缺点与争议(为什么很多互联网公司不用):
- 性能开销:每次对从表进行
INSERT
/UPDATE
,或者对主表进行DELETE
/UPDATE
,数据库都需要进行一次额外的检查,这在高并发的写入场景下,会带来一定的性能损耗。 - 数据库的强耦合:外键使得数据库表之间产生了紧密的耦合。在进行数据库迁移、分库分表等架构演进时,外键会成为一个巨大的障碍。
- 增加了数据库的复杂性:级联操作虽然方便,但也可能因为一次误操作导致数据的意外连锁删除,存在一定风险。
- 性能开销:每次对从表进行
选型结论
- 在一些传统的、对数据一致性要求极高、且写入并发不那么夸张的企业级应用(如ERP、CRM) 中,使用外键是一个非常好的实践。
- 但在高并发、高流量、追求极致性能和快速迭代的互联网应用中,很多公司会选择放弃使用物理外键。他们会把数据一致性的保证,上移到应用层的业务逻辑中去实现,以换取数据库的更高性能和更大的架构灵活性。
总结一下,外键是保证数据引用完整性的强大数据库特性。但在实践中,我们需要在 “数据的强一致性” 和 “系统的高性能与灵活性” 之间,根据具体的业务场景和架构目标,做出一个明智的权衡。
MySQL的关键字IN和EXIST
面试官您好,IN
和EXISTS
是SQL中两个非常重要的关键字,它们都用于子查询中,来实现“一个表中的记录是否存在于另一个表中”的判断。但它们的底层执行逻辑完全不同,这也导致了它们在不同场景下的性能表现差异巨大。
我通常会用一个简单的比喻来区分它们:
IN
:先把“客人名单”全拿过来,再看“酒店住客”是否在名单上。EXISTS
:拿着“酒店住客”的名字,去“客人名单”里挨个问:“你是不是叫这个名字?”
我们用一个具体的例子来分析:
-- 查询所有有学生的班级信息
-- 表A: classes (班级表)
-- 表B: students (学生表)
1. IN
的工作原理
- SQL写法:
SELECT * FROM classes WHERE id IN (SELECT class_id FROM students);
- 执行逻辑:
- 首先,执行子查询:它会先完整地执行括号里的子查询
SELECT class_id FROM students
,并将所有查询到的class_id
(比如[101, 102, 101, 103, ...]
)构建成一个内存中的临时集合或哈希表。如果子查询结果集很大,这里可能会有较大的内存开销。 - 然后,执行外层查询:接着,它会遍历外层表
classes
的每一行。 - 进行判断:对于
classes
表中的每一行,它会拿着这一行的id
,去上一步构建好的那个内存集合中进行查找,判断是否存在。
- 首先,执行子查询:它会先完整地执行括号里的子查询
- 核心特点:先执行子查询,再执行主查询。 子查询只执行一次。
2. EXISTS
的工作原理
- SQL写法:
SELECT * FROM classes c WHERE EXISTS (SELECT 1 FROM students s WHERE s.class_id = c.id);
- 执行逻辑:
- 首先,执行外层查询:它会先遍历外层表
classes
的第一行。 - 然后,执行子查询(关联查询):拿到外层第一行的
c.id
后,它会去执行括号里的子查询SELECT 1 FROM students s WHERE s.class_id = c.id
。 - 进行判断:
- 子查询的目的不是为了返回数据,而只是为了判断 “是否存在匹配的行” 。所以我们通常写
SELECT 1
或SELECT *
,性能没区别。 - 只要子查询能找到哪怕一行匹配的数据,它就会立即停止执行,并向外层返回
TRUE
。 - 如果子查询扫描了整个
students
表都没有找到匹配的行,它就向外层返回FALSE
。
- 子查询的目的不是为了返回数据,而只是为了判断 “是否存在匹配的行” 。所以我们通常写
- 循环:接着,它会继续取
classes
表的第二行,重复第2、3步,直到遍历完整个classes
表。
- 首先,执行外层查询:它会先遍历外层表
- 核心特点:先执行主查询,子查询的执行次数取决于主查询的结果集大小。它是一种关联子查询(Correlated Subquery)。
性能对比与选型法则
理解了它们的原理,我们就能得出一个非常经典的性能优化法则:
“小表驱动大表”
-
当子查询的结果集(
students
表中的class_id
)很小时:- 应该使用
IN
。因为IN
会先把这个小结果集加载到内存里,外层的大表在进行匹配时,是在高效的内存集合里查找,速度很快。 - 如果此时用
EXISTS
,外层的大表有多少行,子查询就要被执行多少次,效率会很低。
- 应该使用
-
当外层查询的表(
classes
表)很小时:- 应该使用
EXISTS
。因为EXISTS
会先遍历这个小表,子查询的执行次数就很少。 - 并且,
EXISTS
的子查询通常能利用到索引。在WHERE s.class_id = c.id
这个条件上,如果students
表的class_id
字段有索引,那么每次子查询都会非常快。 - 如果此时用
IN
,它会先去执行那个大结果集的子查询,可能会消耗大量时间和内存。
- 应该使用
一个简单好记的结论:
- 外大内小,用
IN
(外层表大,子查询结果小) - 外小内大,用
EXISTS
(外层表小,子查询会扫描的表大)
关于 NOT IN
和 NOT EXISTS
这个法则在NOT
的场景下,结论通常是相反的,但更重要的是:
NOT IN
有一个巨大的“陷阱”:如果子查询的结果集中包含了任何NULL
值,那么NOT IN
的整个查询结果将永远为空,这通常不是我们想要的结果。NOT EXISTS
则没有这个问题,它的逻辑更严谨。- 因此,在需要进行“不存在”判断时,强烈推荐总是使用
NOT EXISTS
,以避免NOT IN
带来的NULL
值陷阱。
MySQL中的一些基本函数,你知道哪些?
面试官您好,MySQL提供了非常丰富的内置函数,它们极大地增强了SQL的查询和处理能力。在我的日常开发中,我经常会使用到以下几类函数:
1. 字符串函数 (String Functions)
这类函数用于处理和操作字符串,非常常用。
CONCAT(s1, s2, ...)
: 用于拼接多个字符串。比如,CONCAT(last_name, ', ', first_name)
可以得到"Smith, John"
这样的格式。LENGTH(str)
/CHAR_LENGTH(str)
:LENGTH()
返回字符串的字节长度。CHAR_LENGTH()
返回字符串的字符长度。在处理多字节字符(如UTF-8编码的汉字)时,这个区别非常重要。
SUBSTRING(str, pos, len)
: 从字符串中截取子串。UPPER(str)
/LOWER(str)
: 将字符串转换为大写或小写,常用于不区分大小写的查询匹配。REPLACE(str, from_str, to_str)
: 替换字符串中的子串。TRIM(str)
: 去除字符串首尾的空格。FIND_IN_SET(str, strlist)
: 在一个逗号分隔的字符串列表(strlist
)中,查找str
的位置。这在处理一些用逗号分隔存储的标签ID等场景时很有用,但通常不推荐这样设计表结构。GROUP_CONCAT(expr)
: 这是一个聚合函数,可以将一个分组内的多行字符串,用逗号拼接成一个单一的字符串。非常适合做一些“一对多”关系的报表展示。
2. 数值函数 (Numeric Functions)
这类函数用于进行数学运算。
ROUND(x, d)
: 对数字x
进行四舍五入,保留d
位小数。CEIL(x)
/FLOOR(x)
: 向上取整和向下取整。ABS(x)
: 返回数字的绝对值。RAND()
: 生成一个0到1之间的随机数。MOD(n, m)
: 取模运算,等同于n % m
。
3. 日期和时间函数 (Date and Time Functions)
处理日期时间是后端开发的日常,这些函数必不可少。
NOW()
/CURRENT_TIMESTAMP()
: 获取当前的日期和时间。CURDATE()
: 只获取当前日期。CURTIME()
: 只获取当前时间。DATE_FORMAT(date, format)
: 将日期格式化成指定的字符串。比如DATE_FORMAT(NOW(), '%Y-%m-%d')
会得到"2023-10-27"
。STR_TO_DATE(str, format)
:DATE_FORMAT
的逆操作,将字符串解析成日期。DATE_ADD(date, INTERVAL expr unit)
/DATE_SUB(date, INTERVAL expr unit)
: 对日期进行加减运算。比如DATE_ADD(NOW(), INTERVAL 1 DAY)
就是获取明天的日期。DATEDIFF(date1, date2)
: 计算两个日期之间的天数差。
4. 聚合函数 (Aggregate Functions)
这些函数通常与GROUP BY
子句一起使用,用于进行统计计算。
COUNT(expr)
: 计算行数。COUNT(*)
或COUNT(1)
计算总行数,COUNT(column)
计算该列非NULL
值的行数。SUM(expr)
: 求和。AVG(expr)
: 求平均值。MAX(expr)
/MIN(expr)
: 求最大/最小值。
5. 控制流函数 (Control Flow Functions)
这类函数让SQL也能实现一些简单的逻辑判断。
IF(expr1, expr2, expr3)
: 如果expr1
为真,返回expr2
,否则返回expr3
。类似于Java中的三元运算符。IFNULL(expr1, expr2)
: 如果expr1
不为NULL
,返回expr1
,否则返回expr2
。非常适合用来处理NULL
值的默认显示。CASE ... WHEN ... THEN ... ELSE ... END
: 实现更复杂的多条件判断,类似于Java中的switch
或多重if-else
。
6. 类型转换函数 (Cast Functions)
CAST(expr AS type)
/CONVERT(expr, type)
: 用于显式地进行数据类型转换。
熟练地运用这些内置函数,可以让我们将很多原本需要在Java代码中处理的逻辑,下沉到数据库层面来完成,通常能获得更好的性能,并且让SQL查询本身更具表现力。
SQL查询语句的执行顺序是怎么样的?
面试官您好,SQL查询语句的执行顺序,是一个非常重要的基础概念。它指的是数据库查询引擎在逻辑上处理一个查询的步骤顺序,这个顺序与我们编写SQL语句的顺序有很大的不同。
理解这个逻辑执行顺序,对于我们理解SQL性能优化(比如索引为什么会生效)至关重要。
1. 我们编写的SQL顺序
通常,我们编写一个复杂的SELECT语句,其顺序是这样的:
SELECT DISTINCT ... -- (5)
FROM ... -- (1)
JOIN ... ON ...
WHERE ... -- (2)
GROUP BY ... -- (3)
HAVING ... -- (4)
ORDER BY ... -- (6)
LIMIT ... -- (7)
2. 数据库逻辑上的执行顺序
而数据库在解析和执行这个查询时,其逻辑上的处理流程,大致遵循以下顺序:
第一步:FROM
和 JOIN
—— 确定数据源
- 1.
FROM
: 首先,确定查询的主表。 - 2.
ON
: 根据ON
子句中的连接条件,将JOIN
的表与主表进行连接,生成一个临时的、巨大的笛卡尔积。 - 3.
JOIN
: 根据JOIN
的类型(INNER
,LEFT
,RIGHT
),从这个笛卡尔积中筛选出符合连接条件的行,形成一个虚拟的中间表(Virtual Table, VT1)。
第二步:WHERE
—— 行级过滤
- 4.
WHERE
: 对上一步生成的虚拟表VT1,逐行应用WHERE
子句中的条件进行过滤。只有满足条件的行才会被保留下来,形成第二个虚拟表(VT2)。- 这个阶段是索引大显身手的地方。如果
WHERE
条件中的字段有索引,数据库就能高效地进行过滤,而无需全表扫描。
- 这个阶段是索引大显身手的地方。如果
第三步:GROUP BY
—— 分组
- 5.
GROUP BY
: 如果有GROUP BY
子句,数据库会将VT2中的行,按照指定的列进行分组,形成多个组。每个组会聚合成一条记录,形成第三个虚拟表(VT3)。
第四步:HAVING
—— 组级过滤
- 6.
HAVING
: 对上一步分组后的结果(VT3),应用HAVING
子句中的条件进行过滤。只有满足条件的分组才会被保留下来,形成第四个虚拟表(VT4)。HAVING
与WHERE
的关键区别:WHERE
在分组前对行进行过滤;HAVING
在分组后对组进行过滤。HAVING
子句中可以使用聚合函数(如COUNT(*) > 5
),而WHERE
中不能。
第五步:SELECT
—— 选取列
- 7.
SELECT
: 现在,查询引擎才真正开始处理SELECT
子句。它会从上一步的结果(VT4)中,选取出我们最终需要的那些列,并可以进行计算、使用函数等,形成第五个虚拟表(VT5)。
第六步:DISTINCT
—— 去重
- 8.
DISTINCT
: 如果SELECT
后面有DISTINCT
关键字,引擎会对VT5中的结果进行去重,形成第六个虚拟表(VT6)。
第七步:ORDER BY
—— 排序
- 9.
ORDER BY
: 对上一步的结果(VT6),按照ORDER BY
子句中指定的列和顺序进行排序,形成第七个虚拟表(VT7)。- 注意:
ORDER BY
通常在最后阶段执行,所以如果排序的字段没有索引,当结果集很大时,这个排序操作会非常耗费内存和CPU。
- 注意:
第八步:LIMIT
/ OFFSET
—— 分页
- 10.
LIMIT
: 最后,如果有利LIMIT
子句,引擎会从排序好的结果(VT7)中,截取出指定范围的行,作为最终的查询结果返回给客户端。
总结
用一个流程图来概括就是:
FROM/JOIN
-> WHERE
-> GROUP BY
-> HAVING
-> SELECT
-> DISTINCT
-> ORDER BY
-> LIMIT
这个逻辑执行顺序解释了很多SQL现象,比如:
- 为什么
WHERE
子句中不能使用SELECT
中定义的别名?因为SELECT
在WHERE
之后才执行。 - 为什么
ORDER BY
可以用别名?因为它在SELECT
之后执行。
理解这个顺序,是编写正确、高效SQL的基石。
SQL题:给学生表、课程成绩表,求不存在01课程但存在02课程的学生的成绩
前提:表结构假设
我们先假设有两张表:
student
(学生表)
s_id | s_name |
---|---|
01 | 赵雷 |
02 | 钱电 |
03 | 孙风 |
04 | 李云 |
05 | 周梅 |
score
(课程成绩表)
s_id | c_id | s_score |
---|---|---|
01 | 01 | 80 |
01 | 02 | 90 |
01 | 03 | 99 |
02 | 01 | 70 |
02 | 02 | 60 |
02 | 03 | 80 |
03 | 01 | 80 |
03 | 02 | 80 |
03 | 03 | 80 |
04 | 01 | 50 |
04 | 03 | 20 |
05 | 02 | 76 |
05 | 03 | 87 |
目标:找出“周梅”这位同学,并返回他/她的成绩。
解法一:使用 IN
和 NOT IN
(最直观)
这种解法最符合我们人类的思考逻辑。
-
思路:
- 第一步:找出所有选了
'02'
课程的学生的ID (s_id
)。 - 第二步:找出所有选了
'01'
课程的学生的ID (s_id
)。 - 第三步:从第一步的结果集中,筛选出那些不在第二步结果集中的学生ID。
- 第四步:根据筛选出的学生ID,去
score
表中查询他们的所有成绩。
- 第一步:找出所有选了
-
SQL实现:
SELECT * FROM score WHERE s_id IN (-- 步骤3: 找出只选了02,没选01的学生IDSELECT s_idFROM scoreWHERE c_id = '02'AND s_id NOT IN (-- 步骤2: 所有选了01课程的学生IDSELECT s_idFROM scoreWHERE c_id = '01') );
- 注意:这种写法在
NOT IN
的子查询中,如果s_id
可能为NULL
,可能会产生意想不到的结果。使用NOT EXISTS
通常更健壮。
- 注意:这种写法在
解法二:使用 LEFT JOIN
和 IS NULL
(性能通常更好)
这种解法通过LEFT JOIN
来巧妙地实现“差集”的逻辑。
-
思路:
- 先找出所有选了
'02'
课程的学生记录。 - 将这个结果集,与所有选了
'01'
课程的学生记录进行左连接,连接条件是s_id
相等。 - 如果一个选了
'02'
课程的学生,也选了'01'
课程,那么左连接一定能成功匹配上,右边的字段将不会是NULL
。 - 反之,如果一个选了
'02'
课程的学生,没有选'01'
课程,那么左连接会失败,右边的字段将全部为NULL
。 - 我们只需要筛选出那些连接后右边字段为
NULL
的记录,就找到了目标学生。
- 先找出所有选了
-
SQL实现:
-- 先找出目标学生ID SELECT s02.s_id FROM-- t1: 所有选了02课程的学生记录(SELECT * FROM score WHERE c_id = '02') AS s02 LEFT JOIN-- t2: 所有选了01课程的学生记录(SELECT * FROM score WHERE c_id = '01') AS s01 ON s02.s_id = s01.s_id WHERE-- 关键:筛选出那些在t2中找不到匹配的记录s01.s_id IS NULL;-- 然后可以用这个结果作为子查询,去score表里查成绩 SELECT * FROM score WHERE s_id IN (SELECT s02.s_idFROM (SELECT s_id FROM score WHERE c_id = '02') AS s02LEFT JOIN (SELECT s_id FROM score WHERE c_id = '01') AS s01 ON s02.s_id = s01.s_idWHERE s01.s_id IS NULL );
解法三:使用 GROUP BY
和 HAVING
(思路巧妙)
这种解法利用了分组和聚合函数来在一个查询中完成筛选。
-
思路:
- 按学生ID (
s_id
) 进行分组。 - 在每个分组内,我们去统计这个学生选了
'01'
课程的次数和选了'02'
课程的次数。 - 使用
HAVING
子句来筛选出那些 “选了'02'
课程的次数大于0,并且选了'01'
课程的次数等于0” 的分组。 - 这些分组的
s_id
就是我们目标学生的ID。
- 按学生ID (
-
SQL实现:
SELECT s_id FROM score WHERE c_id IN ('01', '02') -- 先缩小范围,只关心这两门课 GROUP BY s_id HAVING-- 确保选了'02'SUM(CASE WHEN c_id = '02' THEN 1 ELSE 0 END) > 0 AND-- 确保没选'01'SUM(CASE WHEN c_id = '01' THEN 1 ELSE 0 END) = 0;-- 同样,可以用这个结果作为子查询 SELECT * FROM score WHERE s_id IN (SELECT s_idFROM scoreWHERE c_id IN ('01', '02')GROUP BY s_idHAVING SUM(CASE WHEN c_id = '02' THEN 1 ELSE 0 END) > 0AND SUM(CASE WHEN c_id = '01' THEN 1 ELSE 0 END) = 0 );
总结与推荐
- 解法一 (
NOT IN
):最符合直觉,但要注意NULL
值陷阱,性能在子查询结果集大时可能不佳。 - 解法二 (
LEFT JOIN
):通常被认为是性能较好且逻辑严谨的“差集”实现方式。 - 解法三 (
GROUP BY
/HAVING
):思路非常巧妙,可以在一次扫描和分组中完成任务,在某些情况下性能可能最好。
在面试中,能写出解法一说明SQL基础合格,能写出解法二或解法三,则更能体现您对SQL查询优化的理解和灵活运用能力。
SQL题:给定一个学生表 student_score(stu_id,subject_id, score),查询总分排名在5-10名的学生id及对应的总分
前提:表结构假设
student_score
(学生成绩表)
stu_id | subject_id | score |
---|---|---|
S01 | C01 | 80 |
S01 | C02 | 90 |
S02 | C01 | 70 |
S02 | C02 | 60 |
… | … | … |
解法一:使用窗口函数 (Window Functions) —— 推荐的最佳实践
这是在支持窗口函数的数据库(如MySQL 8.0+, PostgreSQL, Oracle等)中,最简洁、最高效、最推荐的解法。
-
思路:
- 使用
GROUP BY
子句,按stu_id
分组,并用SUM(score)
计算出每个学生的总分。 - 在第一步的结果集之上,使用
DENSE_RANK()
或RANK()
窗口函数,对总分(total_score
)进行降序排名。 - 最后,将整个带有排名的结果作为一个子查询(或CTE),在外层查询中筛选出排名在5到10之间的记录。
- 使用
-
为什么用
DENSE_RANK()
或RANK()
?RANK()
:如果出现并列名次,会跳过之后的排名。比如,两个人并列第2,那么下一个名次就是第4。DENSE_RANK()
:如果出现并列名次,不会跳过之后的排名。比如,两个人并列第2,下一个名次仍然是第3。在大多数“Top N”的场景中,DENSE_RANK()
更符合业务直觉。ROW_NUMBER()
:不考虑并列,为每一行分配一个唯一的、连续的排名。
-
SQL实现 (使用
DENSE_RANK
):-- 使用CTE (Common Table Expression) 让查询更清晰 WITH StudentTotalScores AS (-- 步骤1: 计算每个学生的总分SELECTstu_id,SUM(score) AS total_scoreFROMstudent_scoreGROUP BYstu_id ), RankedScores AS (-- 步骤2: 对总分进行排名SELECTstu_id,total_score,DENSE_RANK() OVER (ORDER BY total_score DESC) AS score_rankFROMStudentTotalScores ) -- 步骤3: 筛选出排名在5到10之间的学生 SELECTstu_id,total_score FROMRankedScores WHEREscore_rank BETWEEN 5 AND 10;
- 不使用CTE的写法:
SELECT stu_id, total_score FROM (SELECTstu_id,total_score,DENSE_RANK() OVER (ORDER BY total_score DESC) AS score_rankFROM (SELECT stu_id, SUM(score) AS total_scoreFROM student_scoreGROUP BY stu_id) AS TotalScores ) AS RankedScores WHERE score_rank BETWEEN 5 AND 10;
解法二:使用 LIMIT
和 OFFSET
(兼容旧版MySQL)
在不支持窗口函数的旧版MySQL中,我们可以通过先排序,再使用LIMIT
和OFFSET
来模拟这个分页查询。
-
思路:
- 先计算出每个学生的总分,并按总分降序排列。
- 使用
LIMIT
子句来获取指定范围的记录。LIMIT 5, 5
或者LIMIT 5 OFFSET 5
都意味着“跳过前5条记录,然后取接下来的5条记录”,这恰好就是排名第6到第10。
- 注意:
LIMIT
的第一个参数是offset
(偏移量),第二个参数是count
(数量)。LIMIT 10
等价于LIMIT 0, 10
。
-
SQL实现:
SELECTstu_id,SUM(score) AS total_score FROMstudent_score GROUP BYstu_id ORDER BYtotal_score DESC -- 跳过前4名 (第1, 2, 3, 4名),然后取6条记录 (第5, 6, 7, 8, 9, 10名) LIMIT 6 OFFSET 4;
- 或者更直观的写法:
LIMIT 4, 6
(从第5条记录开始,取6条)
- 或者更直观的写法:
-
这种方法的局限性:
- 无法正确处理并列排名。
LIMIT
只是简单地按物理行号来截取,如果第4名和第5名是并列的,这种方法可能会错误地将并列第4名的某个学生排除掉。而窗口函数则能完美处理并列情况。
- 无法正确处理并列排名。
总结
- 在支持窗口函数的现代数据库中,使用
DENSE_RANK()
或RANK()
是解决此类排名问题的标准、最佳实践,因为它逻辑清晰,并且能正确处理并列排名。 - 在旧版MySQL等不支持窗口函数的环境中,可以使用
ORDER BY
+LIMIT
作为一种近似的、简化的解决方案,但必须清楚地意识到它无法处理并列排名的问题。
SQL题:查某个班级下所有学生的选课情况
前提:表结构假设
我们先假设有三张表:
students
(学生信息表)
s_id | s_name |
---|---|
S01 | 张三 |
S02 | 李四 |
S03 | 王五 |
S04 | 赵六 |
classes
(学生班级表)
s_id | class_name |
---|---|
S01 | 一班 |
S02 | 一班 |
S03 | 二班 |
S04 | 一班 |
student_courses
(学生选课表)
s_id | course_name |
---|---|
S01 | 语文 |
S01 | 数学 |
S02 | 语文 |
S03 | 物理 |
S04 | 数学 |
S04 | 英语 |
目标:查询“一班”所有学生的选课情况。
解法一:使用 INNER JOIN
(最直接)
这是最基础、最直接的解法,通过多级JOIN
将三张表关联起来。
-
思路:
- 以
students
表为基础。 - 用
students.s_id
和classes.s_id
连接classes
表,以获取班级信息。 - 用
students.s_id
和student_courses.s_id
连接student_courses
表,以获取选课信息。 - 最后,用
WHERE
子句筛选出class_name = '一班'
的记录。
- 以
-
SQL实现:
SELECTs.s_id,s.s_name,c.class_name,sc.course_name FROMstudents s INNER JOINclasses c ON s.s_id = c.s_id INNER JOINstudent_courses sc ON s.s_id = sc.s_id WHEREc.class_name = '一班';
-
查询结果:
s_id s_name class_name course_name S01 张三 一班 语文 S01 张三 一班 数学 S02 李四 一班 语文 S04 赵六 一班 数学 S04 赵六 一班 英语 -
优点:逻辑清晰,易于理解。
-
缺点:如果某个学生没有选任何课,那么他将不会出现在结果中。如果需求是“即使没选课也要展示出来”,就需要用
LEFT JOIN
。
解法二:使用 LEFT JOIN
(展示所有学生,包括未选课的)
如果需要展示班级里所有学生,无论他们是否选了课,LEFT JOIN
是更好的选择。
-
思路:与解法一类似,但将连接
student_courses
表的INNER JOIN
改为LEFT JOIN
。 -
SQL实现:
SELECTs.s_id,s.s_name,c.class_name,sc.course_name -- 如果没选课,这里会是NULL FROMstudents s INNER JOINclasses c ON s.s_id = c.s_id LEFT JOIN -- 使用LEFT JOINstudent_courses sc ON s.s_id = sc.s_id WHEREc.class_name = '一班';
-
优点:能保证“一班”的所有学生都会出现在结果中,信息更完整。
解法三:使用 GROUP_CONCAT
(将选课情况合并展示)
有时候,我们不希望每个学生的每门课都占一行,而是希望每个学生只占一行,他选的所有课程合并在一个字段里显示。
-
思路:
- 先像解法一或解法二一样,连接所有表并筛选出“一班”的学生。
- 在得到的结果集上,按学生ID和姓名进行
GROUP BY
分组。 - 使用
GROUP_CONCAT()
聚合函数,将每个学生分组内的所有course_name
用逗号拼接起来。
-
SQL实现:
SELECTs.s_id,s.s_name,c.class_name,-- 使用GROUP_CONCAT将课程名拼接GROUP_CONCAT(sc.course_name SEPARATOR ', ') AS courses FROMstudents s INNER JOINclasses c ON s.s_id = c.s_id LEFT JOIN -- 这里用LEFT JOIN更好,可以处理没选课的学生student_courses sc ON s.s_id = sc.s_id WHEREc.class_name = '一班' GROUP BYs.s_id, s.s_name, c.class_name;
-
查询结果:
s_id s_name class_name courses S01 张三 一班 语文, 数学 S02 李四 一班 语文 S04 赵六 一班 数学, 英语 -
优点:结果集更紧凑,可读性更强,非常适合在报表或前端页面直接展示。
总结与推荐
INNER JOIN
:适用于只需要展示有选课记录的学生。LEFT JOIN
:适用于需要展示班级内所有学生,并标明其选课情况(包括未选课)的场景,是更严谨的做法。GROUP_CONCAT
:适用于需要将结果进行聚合展示,让每个学生只占一行的场景,可读性最好。
在面试中,能先写出解法二(LEFT JOIN
),因为它考虑得更周全,然后再根据面试官的追问,给出 解法三(GROUP_CONCAT
) 作为优化展示方案,会是最佳的回答策略。
如何用 MySQL 实现一个可重入的锁?
设计方案
我会设计一个专门的 “锁表”(distributed_locks
) 来记录和管理锁的状态。
第一步:设计锁表 (distributed_locks
)
这张表需要包含以下几个关键字段:
lock_name
(VARCHAR): 锁的唯一名称。我们将使用它作为主键或唯一索引,来保证锁的独占性。owner_id
(VARCHAR): 当前持有锁的所有者标识。这可以是一个线程ID、一个客户端的唯一ID、或者一个请求ID。reentrant_count
(INT): 重入计数器。这是实现可重入性的核心。expire_time
(DATETIME/TIMESTAMP): 锁的过期时间。这是一个非常重要的“保险”机制,用于防止因客户端崩溃而导致锁永远无法被释放(死锁)。
CREATE TABLE distributed_locks (`lock_name` VARCHAR(128) NOT NULL,`owner_id` VARCHAR(128) NOT NULL,`reentrant_count` INT NOT NULL DEFAULT 0,`expire_time` TIMESTAMP NOT NULL,PRIMARY KEY (`lock_name`)
) ENGINE=InnoDB;
第二步:实现lock()
(获取锁)的逻辑
获取锁的逻辑是最复杂的,它必须是原子的。我们不能用简单的“先SELECT
再INSERT
/UPDATE
”的方式,因为在高并发下会有竞态条件。我们会将所有逻辑封装在一个事务中,并利用 SELECT ... FOR UPDATE
这个悲观锁 来保证原子性。
伪代码逻辑 (lock(lockName, ownerId, timeoutSeconds)
):
// 伪Java代码
public boolean lock(String lockName, String ownerId, int timeoutSeconds) {Connection conn = null;try {conn = dataSource.getConnection();conn.setAutoCommit(false); // 开启事务// 1. 使用 SELECT ... FOR UPDATE 悲观地锁定这一行(如果存在的话)// 这会阻塞其他试图同样锁定这行的事务,保证了后续操作的原子性PreparedStatement ps = conn.prepareStatement("SELECT owner_id, reentrant_count, expire_time FROM distributed_locks WHERE lock_name = ? FOR UPDATE");ps.setString(1, lockName);ResultSet rs = ps.executeQuery();if (rs.next()) { // ----- 情况A:锁记录已存在 -----String currentOwner = rs.getString("owner_id");int count = rs.getInt("reentrant_count");Timestamp expire = rs.getTimestamp("expire_time");if (currentOwner.equals(ownerId)) {// 【可重入性体现】: 锁的持有者是自己,直接增加重入次数PreparedStatement updatePs = conn.prepareStatement("UPDATE distributed_locks SET reentrant_count = reentrant_count + 1 WHERE lock_name = ?");updatePs.setString(1, lockName);updatePs.executeUpdate();} else {// 持有者是别人,检查锁是否已过期if (expire.before(new Timestamp(System.currentTimeMillis()))) {// 锁已过期,抢占它!PreparedStatement updatePs = conn.prepareStatement("UPDATE distributed_locks SET owner_id = ?, reentrant_count = 1, expire_time = ? WHERE lock_name = ?");updatePs.setString(1, ownerId);updatePs.setTimestamp(2, new Timestamp(System.currentTimeMillis() + timeoutSeconds * 1000));updatePs.setString(3, lockName);updatePs.executeUpdate();} else {// 锁未过期,获取失败conn.rollback();return false;}}} else { // ----- 情况B:锁记录不存在 -----// 没有人持有锁,直接插入新记录来获取锁PreparedStatement insertPs = conn.prepareStatement("INSERT INTO distributed_locks (lock_name, owner_id, reentrant_count, expire_time) VALUES (?, ?, 1, ?)");insertPs.setString(1, lockName);insertPs.setString(2, ownerId);insertPs.setTimestamp(3, new Timestamp(System.currentTimeMillis() + timeoutSeconds * 1000));insertPs.executeUpdate();}conn.commit(); // 提交事务return true;} catch (Exception e) {if (conn != null) conn.rollback();// 异常处理return false;} finally {if (conn != null) conn.close();}
}
第三步:实现unlock()
(释放锁)的逻辑
释放锁的逻辑相对简单,但同样需要在事务中进行。
伪代码逻辑 (unlock(lockName, ownerId)
):
public boolean unlock(String lockName, String ownerId) {Connection conn = null;try {conn = dataSource.getConnection();conn.setAutoCommit(false);// 同样,先锁定这一行,防止并发修改PreparedStatement ps = conn.prepareStatement("SELECT owner_id, reentrant_count FROM distributed_locks WHERE lock_name = ? FOR UPDATE");ps.setString(1, lockName);ResultSet rs = ps.executeQuery();if (rs.next()) {String currentOwner = rs.getString("owner_id");if (!currentOwner.equals(ownerId)) {// 如果锁的持有者不是自己,无权释放,这可能是一个严重的逻辑错误conn.rollback();throw new IllegalMonitorStateException("Attempt to unlock a lock not owned by the current thread/client.");}int count = rs.getInt("reentrant_count");if (count > 1) {// 【可重入性体现】: 只是减少重入次数,并不真正释放锁PreparedStatement updatePs = conn.prepareStatement("UPDATE distributed_locks SET reentrant_count = reentrant_count - 1 WHERE lock_name = ?");updatePs.setString(1, lockName);updatePs.executeUpdate();} else {// 重入次数为1,这是最后一次释放,直接删除锁记录PreparedStatement deletePs = conn.prepareStatement("DELETE FROM distributed_locks WHERE lock_name = ?");deletePs.setString(1, lockName);deletePs.executeUpdate();}} else {// 锁记录本就不存在,可能也是一个逻辑错误conn.rollback();// log a warning or do nothingreturn true;}conn.commit();return true;} catch (Exception e) {if (conn != null) conn.rollback();return false;} finally {if (conn != null) conn.close();}
}
总结
这个设计的核心在于:
- 利用数据库主键或唯一索引的唯一性,来保证锁的独占性。
- 利用
SELECT ... FOR UPDATE
悲观锁和事务,来保证“检查-再操作”这个过程的原子性。 - 引入一个
reentrant_count
计数器,在lock()
和unlock()
时进行增减,来实现可重入性。 - 引入一个
expire_time
过期时间,作为兜底机制,防止因持有者宕机而导致的永久死锁。
通过这套设计,我们就能在MySQL层面,模拟出一个功能相对完备的、可重入的分布式锁。当然,在生产环境中,我们通常会优先选择像Redis的RedLock或ZooKeeper这样更专业的分布式锁实现。
参考小林coding