面试官您好,SQL(关系型数据库)和NoSQL(非关系型数据库)是当今数据存储领域的两大主流阵营。它们之间不是“谁取代谁”的关系,而是两种完全不同的设计哲学,适用于解决不同类型的问题。
我通常会从以下几个核心维度来对比它们:
SQL (关系型数据库):
NoSQL (非关系型数据库):
{key: value}
形式存储,非常高效。代表:Redis, Memcached。SQL:
NoSQL:
SQL:
NoSQL:
SQL:
NoSQL:
特性 | SQL (关系型) | NoSQL (非关系型) |
---|---|---|
数据模型 | 结构化 (二维表, 强Schema) | 多样化 (Key-Value, 文档等, 弱/无Schema) |
一致性 | ACID (强一致性) | BASE (最终一致性) |
扩展性 | 垂直扩展 (Scale-up) 为主 | 水平扩展 (Scale-out) 为主 |
事务 | 强大 | 弱或不支持 |
适用场景 | 事务性强的、数据关系复杂的应用 | 高并发、海量数据、结构不固定的应用 |
我的选型策略:
什么时候选择SQL数据库?
什么时候选择NoSQL数据库?
在现代架构中,我们通常不会只选择一种,而是将SQL和NoSQL数据库组合使用,让它们各自在最擅长的领域发挥作用,以构建出更健壮、性能更高的系统。
面试官您好,数据库的三大范式(Normal Forms, NF)是我们在进行关系型数据库逻辑设计时,所遵循的一套基本准则和规范。
它的核心目标,是通过对表结构的合理设计,来减少数据冗余、避免数据异常(如插入异常、更新异常、删除异常),从而保证数据的一致性和完整性。
我来分别解释一下这三大范式,并用一个例子来贯穿。
假设我们有一个未优化的“订单信息表”:
原始表:订单表 (Order_Info)
订单ID | 顾客姓名 | 顾客电话 | 商品ID | 商品名称 | 商品单价 | 购买数量 |
---|---|---|---|---|---|---|
O001 | 张三 | 138… | P01 | 手机 | 5000 | 1 |
O001 | 张三 | 138… | P02 | 耳机 | 200 | 2 |
O002 | 李四 | 139… | P01 | 手机 | 5000 | 1 |
商品
字段,里面存的是"P01:手机:5000, P02:耳机:200"
,这就违反了1NF。购买数量
:它既依赖于订单ID
,也依赖于商品ID
,是完全依赖。顾客姓名
, 顾客电话
:它们只依赖于订单ID
,与商品ID
无关。这是部分依赖。商品名称
, 商品单价
:它们只依赖于商品ID
,与订单ID
无关。这也是部分依赖。订单ID
)
订单ID | 顾客姓名 | 顾客电话 |
---|---|---|
O001 | 张三 | 138… |
O002 | 李四 | 139… |
商品ID
)
商品ID | 商品名称 | 商品单价 |
---|---|---|
P01 | 手机 | 5000 |
P02 | 耳机 | 200 |
订单ID
, 商品ID
)
订单ID | 商品ID | 购买数量 |
---|---|---|
O001 | P01 | 1 |
O001 | P02 | 2 |
O002 | P01 | 1 |
顾客姓名
和商品名称
等信息不再冗余存储,更新时也不会出现数据不一致的问题。定义:在满足第二范式的基础上,要求表中的任何非主键字段,都不能依赖于其他非主键字段。
核心思想:确保所有非主键字段都直接依赖于主键,而不是通过“跳板”间接依赖。
如何分析我们的例子:
顾客姓名
和顾客电话
是直接依赖于主键订单ID
吗?订单ID
决定了是哪个顾客,而顾客才决定了他的姓名和电话。这里存在一个传递依赖:订单ID -> 顾客 -> (顾客姓名, 顾客电话)
。如何改造(再次拆分):
顾客ID
)
顾客ID | 顾客姓名 | 顾客电话 |
---|---|---|
C01 | 张三 | 138… |
C02 | 李四 | 139… |
订单ID
)
订单ID | 顾客ID (外键) |
---|---|
O001 | C01 |
O002 | C02 |
好处:现在,顾客的信息是独立维护的,如果一个顾客改了电话,我们只需要修改Customers
表的一行,所有与他相关的订单信息都能保持一致。
在实际的数据库设计中,我们通常会力求满足第三范式(3NF),这能最大程度地减少数据冗余,保证数据一致性。但有时,为了查询性能,我们也会进行 “反范式化” 设计,适度地增加一些冗余字段来避免复杂的多表连接(JOIN)查询,这是一种在“数据一致性”和“查询效率”之间的权衡。
面试官您好,在MySQL(以及大多数关系型数据库)中,当我们需要从多个关联的表中获取数据时,就需要使用连接查询(JOIN)。
最核心的连接查询主要有以下几种。我们可以用两张简单的示例表来直观地理解它们的区别:
students
表 (学生表)
id | name | class_id |
---|---|---|
1 | 张三 | 101 |
2 | 李四 | 102 |
3 | 王五 | 103 |
classes
表 (班级表)
id | name |
---|---|
101 | 一班 |
102 | 二班 |
104 | 四班 |
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
表中没有学生关联,也被排除。)FROM
子句后的第一个表)的所有行,即使在右表中没有匹配的记录。NULL
。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 |
JOIN
子句后的表)的所有行,即使在左表中没有匹配的记录。NULL
。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
。可以看作是左连接和右连接结果的并集。FULL OUTER JOIN
关键字。但我们可以通过 LEFT JOIN
UNION RIGHT JOIN
来模拟实现。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 | 四班 |
ON
条件时,是INNER JOIN
的默认行为,通常需要谨慎使用。总结一下,在选择连接方式时,我主要考虑:
INNER JOIN
。LEFT JOIN
或 RIGHT JOIN
。FULL OUTER JOIN
(在MySQL中用UNION模拟)。面试官您好,在MySQL中避免重复插入数据,是一个保证数据唯一性和完整性的核心问题。我会从数据库表结构层面(治本) 和SQL语句层面(治标) 这两个维度来设计解决方案。
这是最根本、最可靠的解决方案,它利用数据库自身的能力来强制保证数据的唯一性。
设置主键 (PRIMARY KEY)
建立唯一索引 (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语法。
INSERT IGNORE INTO
INSERT
操作时,如果因为唯一键冲突(主键或唯一索引)而导致插入失败,这条INSERT
语句会被默默地忽略掉,不会产生任何错误。INSERT IGNORE INTO users (username, email) VALUES ('admin', '[email protected]');
REPLACE INTO
REPLACE
操作时,如果唯一键冲突,它会先删除那条旧的记录,然后再插入一条新的记录。REPLACE INTO users (id, username, email) VALUES (1, 'admin_new', '[email protected]');
DELETE
+ INSERT
,如果表上有触发器,会先后触发删除和插入的触发器。并且,如果id
是自增的,它会消耗一个新的ID,而不是更新旧的。需要非常谨慎使用。INSERT INTO ... ON DUPLICATE KEY UPDATE
(推荐)
INSERT
操作时,如果发生唯一键冲突,它不会报错,而是会转而去执行UPDATE
子句中指定的更新逻辑。INSERT INTO users (username, last_login_time)
VALUES ('admin', NOW())
ON DUPLICATE KEY UPDATE
last_login_time = NOW();
Upsert
)的逻辑,非常高效和方便。INSERT IGNORE
。ON DUPLICATE KEY UPDATE
。SELECT
来判断数据是否存在。这在并发量低时可行,但在高并发下,从SELECT
到INSERT
之间存在时间窗口,可能导致竞态条件,不推荐。通过这两层防线的结合,我们就能非常健壮地处理数据重复插入的问题。
面试官您好,CHAR
和VARCHAR
是MySQL中最常用的两种字符串类型,它们最核心的区别在于其长度的存储和处理方式,这个区别直接导致了它们在存储空间、性能和适用场景上的巨大差异。
CHAR(N)
(定长)
CHAR
是一种固定长度的字符串类型。当我们定义一个CHAR(10)
的字段时,无论我们实际存入的数据是"abc"
(3个字符)还是"hello"
(5个字符),它在数据库中永远都会占用10个字符的存储空间。N
,MySQL会在其右侧用空格进行填充,以补足到指定的长度。在读取时,这些尾部的空格通常会被自动去除(除非SQL_MODE有特殊设置)。VARCHAR(N)
(变长)
VARCHAR
是一种可变长度的字符串类型。N
在这里代表的是最大长度。N
小于等于255,用1个字节记录长度。N
大于255,用2个字节记录长度。特性 | CHAR(N) |
VARCHAR(N) |
---|---|---|
空间开销 | 固定,可能浪费空间 | 可变,通常更节省空间 |
更新效率 | 更高、更稳定 | 可能导致性能问题 |
碎片化 | 不易产生 | 容易产生 |
空间对比:
N
时,CHAR
可能比VARCHAR
更节省空间,因为它省去了那1-2个字节的长度记录开销。VARCHAR
的优势就非常明显了,能极大地节约磁盘空间。性能对比(这是一个更深入的考量点):
CHAR
的优势:因为长度固定,CHAR
类型的字段在进行更新(UPDATE)操作时,通常不会改变记录的物理长度。这使得数据库在原地更新数据变得非常容易,不容易导致行迁移(Row Migration)或页分裂(Page Split),因此更新性能更稳定。VARCHAR
的劣势:如果一个VARCHAR
字段的值从一个短字符串更新为一个长字符串(比如从"hi"
更新为"hello world"
),导致该行的总长度超出了当前数据页的剩余空间,就可能触发代价高昂的页分裂操作,从而影响性能。基于以上对比,我的选型策略非常明确:
什么时候选择CHAR
?
CHAR
最理想的场景。
CHAR
的性能和空间优势也可能超过VARCHAR
。什么时候选择VARCHAR
?
VARCHAR
。
VARCHAR
时,一个重要的最佳实践是为N
设置一个合理的、尽可能小的最大长度。比如,一个用户名字段,设置成VARCHAR(50)
就比VARCHAR(255)
要好得多。这不仅能节省空间,还能利用到MySQL的内存优化(比如在排序时可以使用内存临时表)。总结一下,CHAR
追求的是处理速度和性能的稳定性,以可能浪费空间为代价;而VARCHAR
追求的是空间的极致利用,以可能在更新时产生一些性能开销为代价。在实际设计中,我们需要根据数据的具体特性来做出最合适的选择。
面试官您好,这是一个非常好的细节问题,也是很多开发者容易混淆的地方。
在现代的MySQL版本(5.0及以后)中,VARCHAR(N)
括号里的数字N
,明确代表的是“字符数”(Character Count)。
'A'
、一个数字'1'
、或者一个汉字'中'
,都算作一个字符。正如您所说,不同的字符集,每个字符占用的字节数是不同的:
latin1
或 ascii
字符集:
VARCHAR(10)
最多能存10个字符,最大占用10个字节。gbk
字符集:
VARCHAR(10)
仍然能存10个字符,比如10个汉字,此时它会占用10 * 2 = 20
个字节。utf8mb4
字符集 (现在最推荐的通用字符集):
VARCHAR(10)
,它可以存:
VARCHAR
的总物理存储开销,等于真实数据的字节数,再加上1到2个字节用于记录长度的“前缀”。
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)
没有任何区别。
很多初学者会误以为INT(1)
只能存1位数的整数,INT(10)
能存10位数的整数。这是完全错误的。
INT
这个数据类型,无论你怎么写,它在磁盘上占用的存储空间永远是固定的4个字节。INT
,范围是 -2147483648
到 2147483647
;对于无符号的UNSIGNED INT
,范围是 0
到 4294967295
。INT(1)
还是INT(10)
,你都可以往里面存入12345
这样的数字,只要它在INT
的范围内。那么,括号里的这个数字到底是什么意思呢?
ZEROFILL
使用在现代的MySQL客户端和各种编程语言的驱动中,这个“显示宽度”提示基本上已经被完全忽略了。它唯一还能产生可见效果的场景,就是当这个字段同时被设置了 ZEROFILL
属性时。
ZEROFILL
的作用:它会自动地用前导零,来填充数字,使其达到指定的“显示宽度”。id
,类型是 INT(5) ZEROFILL
。
123
,那么查询出来显示时,就会变成 00123
。123456
(超过了显示宽度),它不会被截断,查询出来仍然是 123456
。ZEROFILL
只负责补零,不负责截断。ZEROFILL
,该字段会自动变为 UNSIGNED
(无符号) 。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
数据类型并不是无限大的,它有明确的长度限制。
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
类型。
TEXT
家族中,也应该按需选择最小的类型。比如,如果确认内容不会超过64KB,就用TEXT
,而不是MEDIUMTEXT
或LONGTEXT
,因为更小的类型,其指针和长度记录的开销也更小。总结一下,TEXT
不是无限大的,它是一个为了存储超长文本而设计的“重型武器”。在使用它时,我们必须意识到它带来的性能开销,并遵循“按需查询、避免排序”的最佳实践。
面试官您好,在数据库中存储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,
...
);
优点:
缺点:
"1.1.1.1"
(7个字符),也比整型占用的空间大。最长需要15个字节。LIKE
或者复杂的字符串函数,无法利用索引进行高效的范围扫描。INT
或 BIGINT
) 存储 (推荐)这是更专业、性能更好的方式。
核心思想:IP地址本质上是一个32位(IPv4)或128位(IPv6)的无符号整数。我们可以利用数据库的函数,将其与整数形式进行相互转换。
对于IPv4:
INT UNSIGNED
(4字节无符号整型)来存储。
INET_ATON('ip_address')
: 将点分十进制的IP字符串,转换为一个32位无符号整数。INET_NTOA(integer_ip)
: 将整数形式的IP,转换回点分十进制的字符串。-- 创建表
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:
VARBINARY(16)
来存储其二进制形式(BIGINT
只有8字节,不够用)。INET6_ATON('ipv6_address')
INET6_NTOA(binary_ip)
优点:
INT UNSIGNED
只需要4个字节,相比VARCHAR(15)
,空间占用大大减少。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
是简单可行的。INT UNSIGNED
(或VARBINARY(16)
for IPv6)来存储。这是一种用“可读性”换取“巨大性能和存储优势”的专业做法,是业界的最佳实践。面试官您好,外键(Foreign Key)约束是关系型数据库中一个非常重要的概念,它的核心作用是在两个表之间建立一种强制性的关联关系,以保证数据的引用完整性(Referential Integrity)。
我们可以用一个简单的例子来理解它:一个订单表 (orders
)和一个顾客表 (customers
)。
定义:我们会在“从表”(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
表中是否还有订单在引用这个顾客。
为了更灵活地处理主表记录被删除或更新时的情况,外键约束还提供了几种级联操作策略:
ON DELETE CASCADE
: 这是最常用的级联删除。当主表中的一条记录被删除时(比如删除了一个顾客),所有从表中引用该记录的行(该顾客的所有订单)也会自动地被一并删除。ON DELETE SET NULL
: 当主表记录被删除时,从表中对应行的外键字段会被自动设置为NULL
。这要求该外键字段必须允许为NULL
。ON DELETE RESTRICT
/ ON DELETE NO ACTION
: 这是默认的行为,即如果存在子表记录,就禁止删除主表记录。ON UPDATE
也有类似的级联操作。
优点:
缺点与争议(为什么很多互联网公司不用):
INSERT
/UPDATE
,或者对主表进行DELETE
/UPDATE
,数据库都需要进行一次额外的检查,这在高并发的写入场景下,会带来一定的性能损耗。总结一下,外键是保证数据引用完整性的强大数据库特性。但在实践中,我们需要在 “数据的强一致性” 和 “系统的高性能与灵活性” 之间,根据具体的业务场景和架构目标,做出一个明智的权衡。
面试官您好,IN
和EXISTS
是SQL中两个非常重要的关键字,它们都用于子查询中,来实现“一个表中的记录是否存在于另一个表中”的判断。但它们的底层执行逻辑完全不同,这也导致了它们在不同场景下的性能表现差异巨大。
我通常会用一个简单的比喻来区分它们:
IN
:先把“客人名单”全拿过来,再看“酒店住客”是否在名单上。EXISTS
:拿着“酒店住客”的名字,去“客人名单”里挨个问:“你是不是叫这个名字?”我们用一个具体的例子来分析:
-- 查询所有有学生的班级信息
-- 表A: classes (班级表)
-- 表B: students (学生表)
IN
的工作原理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
,去上一步构建好的那个内存集合中进行查找,判断是否存在。EXISTS
的工作原理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
表。理解了它们的原理,我们就能得出一个非常经典的性能优化法则:
“小表驱动大表”
当子查询的结果集(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提供了非常丰富的内置函数,它们极大地增强了SQL的查询和处理能力。在我的日常开发中,我经常会使用到以下几类函数:
这类函数用于处理和操作字符串,非常常用。
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)
: 这是一个聚合函数,可以将一个分组内的多行字符串,用逗号拼接成一个单一的字符串。非常适合做一些“一对多”关系的报表展示。这类函数用于进行数学运算。
ROUND(x, d)
: 对数字x
进行四舍五入,保留d
位小数。CEIL(x)
/ FLOOR(x)
: 向上取整和向下取整。ABS(x)
: 返回数字的绝对值。RAND()
: 生成一个0到1之间的随机数。MOD(n, m)
: 取模运算,等同于n % m
。处理日期时间是后端开发的日常,这些函数必不可少。
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)
: 计算两个日期之间的天数差。这些函数通常与GROUP BY
子句一起使用,用于进行统计计算。
COUNT(expr)
: 计算行数。COUNT(*)
或COUNT(1)
计算总行数,COUNT(column)
计算该列非NULL
值的行数。SUM(expr)
: 求和。AVG(expr)
: 求平均值。MAX(expr)
/ MIN(expr)
: 求最大/最小值。这类函数让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
。CAST(expr AS type)
/ CONVERT(expr, type)
: 用于显式地进行数据类型转换。熟练地运用这些内置函数,可以让我们将很多原本需要在Java代码中处理的逻辑,下沉到数据库层面来完成,通常能获得更好的性能,并且让SQL查询本身更具表现力。
面试官您好,SQL查询语句的执行顺序,是一个非常重要的基础概念。它指的是数据库查询引擎在逻辑上处理一个查询的步骤顺序,这个顺序与我们编写SQL语句的顺序有很大的不同。
理解这个逻辑执行顺序,对于我们理解SQL性能优化(比如索引为什么会生效)至关重要。
通常,我们编写一个复杂的SELECT语句,其顺序是这样的:
SELECT DISTINCT ... -- (5)
FROM ... -- (1)
JOIN ... ON ...
WHERE ... -- (2)
GROUP BY ... -- (3)
HAVING ... -- (4)
ORDER BY ... -- (6)
LIMIT ... -- (7)
而数据库在解析和执行这个查询时,其逻辑上的处理流程,大致遵循以下顺序:
第一步:FROM
和 JOIN
—— 确定数据源
FROM
: 首先,确定查询的主表。ON
: 根据ON
子句中的连接条件,将JOIN
的表与主表进行连接,生成一个临时的、巨大的笛卡尔积。JOIN
: 根据JOIN
的类型(INNER
, LEFT
, RIGHT
),从这个笛卡尔积中筛选出符合连接条件的行,形成一个虚拟的中间表(Virtual Table, VT1)。第二步:WHERE
—— 行级过滤
WHERE
: 对上一步生成的虚拟表VT1,逐行应用WHERE
子句中的条件进行过滤。只有满足条件的行才会被保留下来,形成第二个虚拟表(VT2)。
WHERE
条件中的字段有索引,数据库就能高效地进行过滤,而无需全表扫描。第三步:GROUP BY
—— 分组
GROUP BY
: 如果有GROUP BY
子句,数据库会将VT2中的行,按照指定的列进行分组,形成多个组。每个组会聚合成一条记录,形成第三个虚拟表(VT3)。第四步:HAVING
—— 组级过滤
HAVING
: 对上一步分组后的结果(VT3),应用HAVING
子句中的条件进行过滤。只有满足条件的分组才会被保留下来,形成第四个虚拟表(VT4)。
HAVING
与WHERE
的关键区别:WHERE
在分组前对行进行过滤;HAVING
在分组后对组进行过滤。HAVING
子句中可以使用聚合函数(如COUNT(*) > 5
),而WHERE
中不能。第五步:SELECT
—— 选取列
SELECT
: 现在,查询引擎才真正开始处理SELECT
子句。它会从上一步的结果(VT4)中,选取出我们最终需要的那些列,并可以进行计算、使用函数等,形成第五个虚拟表(VT5)。第六步:DISTINCT
—— 去重
DISTINCT
: 如果SELECT
后面有DISTINCT
关键字,引擎会对VT5中的结果进行去重,形成第六个虚拟表(VT6)。第七步:ORDER BY
—— 排序
ORDER BY
: 对上一步的结果(VT6),按照ORDER BY
子句中指定的列和顺序进行排序,形成第七个虚拟表(VT7)。
ORDER BY
通常在最后阶段执行,所以如果排序的字段没有索引,当结果集很大时,这个排序操作会非常耗费内存和CPU。第八步:LIMIT
/ OFFSET
—— 分页
LIMIT
: 最后,如果有利LIMIT
子句,引擎会从排序好的结果(VT7)中,截取出指定范围的行,作为最终的查询结果返回给客户端。用一个流程图来概括就是:
FROM/JOIN
-> WHERE
-> GROUP BY
-> HAVING
-> SELECT
-> DISTINCT
-> ORDER BY
-> LIMIT
这个逻辑执行顺序解释了很多SQL现象,比如:
WHERE
子句中不能使用SELECT
中定义的别名?因为SELECT
在WHERE
之后才执行。ORDER BY
可以用别名?因为它在SELECT
之后执行。理解这个顺序,是编写正确、高效SQL的基石。
我们先假设有两张表:
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
)。score
表中查询他们的所有成绩。SQL实现:
SELECT *
FROM score
WHERE s_id IN (
-- 步骤3: 找出只选了02,没选01的学生ID
SELECT s_id
FROM score
WHERE c_id = '02'
AND s_id NOT IN (
-- 步骤2: 所有选了01课程的学生ID
SELECT s_id
FROM score
WHERE 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_id
FROM (SELECT s_id FROM score WHERE c_id = '02') AS s02
LEFT JOIN (SELECT s_id FROM score WHERE c_id = '01') AS s01 ON s02.s_id = s01.s_id
WHERE s01.s_id IS NULL
);
GROUP BY
和 HAVING
(思路巧妙)这种解法利用了分组和聚合函数来在一个查询中完成筛选。
思路:
s_id
) 进行分组。'01'
课程的次数和选了'02'
课程的次数。HAVING
子句来筛选出那些 “选了'02'
课程的次数大于0,并且选了'01'
课程的次数等于0” 的分组。s_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_id
FROM score
WHERE c_id IN ('01', '02')
GROUP BY s_id
HAVING SUM(CASE WHEN c_id = '02' THEN 1 ELSE 0 END) > 0
AND SUM(CASE WHEN c_id = '01' THEN 1 ELSE 0 END) = 0
);
NOT IN
):最符合直觉,但要注意NULL
值陷阱,性能在子查询结果集大时可能不佳。LEFT JOIN
):通常被认为是性能较好且逻辑严谨的“差集”实现方式。GROUP BY
/HAVING
):思路非常巧妙,可以在一次扫描和分组中完成任务,在某些情况下性能可能最好。在面试中,能写出解法一说明SQL基础合格,能写出解法二或解法三,则更能体现您对SQL查询优化的理解和灵活运用能力。
student_score
(学生成绩表)
stu_id | subject_id | score |
---|---|---|
S01 | C01 | 80 |
S01 | C02 | 90 |
S02 | C01 | 70 |
S02 | C02 | 60 |
… | … | … |
这是在支持窗口函数的数据库(如MySQL 8.0+, PostgreSQL, Oracle等)中,最简洁、最高效、最推荐的解法。
思路:
GROUP BY
子句,按stu_id
分组,并用SUM(score)
计算出每个学生的总分。DENSE_RANK()
或RANK()
窗口函数,对总分(total_score
)进行降序排名。为什么用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: 计算每个学生的总分
SELECT
stu_id,
SUM(score) AS total_score
FROM
student_score
GROUP BY
stu_id
),
RankedScores AS (
-- 步骤2: 对总分进行排名
SELECT
stu_id,
total_score,
DENSE_RANK() OVER (ORDER BY total_score DESC) AS score_rank
FROM
StudentTotalScores
)
-- 步骤3: 筛选出排名在5到10之间的学生
SELECT
stu_id,
total_score
FROM
RankedScores
WHERE
score_rank BETWEEN 5 AND 10;
SELECT stu_id, total_score
FROM (
SELECT
stu_id,
total_score,
DENSE_RANK() OVER (ORDER BY total_score DESC) AS score_rank
FROM (
SELECT stu_id, SUM(score) AS total_score
FROM student_score
GROUP 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实现:
SELECT
stu_id,
SUM(score) AS total_score
FROM
student_score
GROUP BY
stu_id
ORDER BY
total_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()
是解决此类排名问题的标准、最佳实践,因为它逻辑清晰,并且能正确处理并列排名。ORDER BY
+ LIMIT
作为一种近似的、简化的解决方案,但必须清楚地意识到它无法处理并列排名的问题。我们先假设有三张表:
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实现:
SELECT
s.s_id,
s.s_name,
c.class_name,
sc.course_name
FROM
students s
INNER JOIN
classes c ON s.s_id = c.s_id
INNER JOIN
student_courses sc ON s.s_id = sc.s_id
WHERE
c.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实现:
SELECT
s.s_id,
s.s_name,
c.class_name,
sc.course_name -- 如果没选课,这里会是NULL
FROM
students s
INNER JOIN
classes c ON s.s_id = c.s_id
LEFT JOIN -- 使用LEFT JOIN
student_courses sc ON s.s_id = sc.s_id
WHERE
c.class_name = '一班';
优点:能保证“一班”的所有学生都会出现在结果中,信息更完整。
GROUP_CONCAT
(将选课情况合并展示)有时候,我们不希望每个学生的每门课都占一行,而是希望每个学生只占一行,他选的所有课程合并在一个字段里显示。
思路:
GROUP BY
分组。GROUP_CONCAT()
聚合函数,将每个学生分组内的所有course_name
用逗号拼接起来。SQL实现:
SELECT
s.s_id,
s.s_name,
c.class_name,
-- 使用GROUP_CONCAT将课程名拼接
GROUP_CONCAT(sc.course_name SEPARATOR ', ') AS courses
FROM
students s
INNER JOIN
classes c ON s.s_id = c.s_id
LEFT JOIN -- 这里用LEFT JOIN更好,可以处理没选课的学生
student_courses sc ON s.s_id = sc.s_id
WHERE
c.class_name = '一班'
GROUP BY
s.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
) 作为优化展示方案,会是最佳的回答策略。
我会设计一个专门的 “锁表”(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 nothing
return 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