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