MySQL锁机制中的死锁是如何产生的

以下内容将从死锁的基本概念入手,结合 MySQL InnoDB 行级锁的工作原理,通过具体的代码示例、ASCII 图解和详细说明,帮助你系统地理解 MySQL 锁机制中的死锁是如何产生的,以及如何检测和解决死锁。


1. 什么是死锁

  • 死锁(Deadlock):指在并发环境下,多个事务各自持有部分资源并相互等待对方释放资源,从而形成无限等待的状态,导致无法继续执行。
  • 在 MySQL InnoDB 引擎中,资源通常是某些行级锁、间隙锁或表锁。当事务 A 持有资源 R1,等待资源 R2;同时事务 B 持有资源 R2,等待资源 R1,就形成了最典型的死锁。

1.1 死锁与普通锁等待的区别

  • 普通锁等待:事务 A 请求锁时,如果资源正在被事务 B 持有,A 会挂起等待,直到 B 提交/回滚并释放锁。等待过程可被唤醒继续执行。
  • 死锁:如果等待关系形成环(环路),例如 A 等待 B,B 又等待 A,两者都永远得不到所需资源,系统就无法继续。InnoDB 会检测到这种环路后,选择其中一个事务回滚,从而解除死锁。

2. InnoDB 中的锁类型概览

在分析死锁形成前,先简单回顾 InnoDB 常见锁类型:

  1. 记录锁(Record Lock)

    • 作用于索引上的单条记录,用来防止并发修改同一行。
  2. 间隙锁(Gap Lock)

    • 锁定索引值之间的空隙,阻止其他事务向间隙中插入新行,用于防止“幻读”。
  3. 临键锁(Next-key Lock)

    • 记录锁 + 间隙锁,既锁住记录,也锁住它左侧的间隙,用于 REPEATABLE READ 隔离下防止幻读。
  4. 意向锁(Intention Lock)

    • 用于表级上标记“此事务意向在表的某个行上加共享锁(IS)或排他锁(IX)”,便于上层快速检测冲突。

死锁往往由多个事务对同一个或多个行 (或间隙) 以不一致顺序地加锁所引起。下面通过示例演示最常见的两种死锁场景。


3. 示例一:两条记录互相更新导致死锁

3.1 场景描述

假设有一张 InnoDB 表 accounts,用于模拟两个账户之间转账场景。表结构与初始数据如下:

CREATE DATABASE IF NOT EXISTS test_deadlock;
USE test_deadlock;

CREATE TABLE accounts (
    id INT PRIMARY KEY,
    balance INT
) ENGINE=InnoDB;

INSERT INTO accounts (id, balance) VALUES
(1, 1000),
(2, 1000);

这时,假设有两个并发事务:

  • 事务 A 想把 账户 1 的 100 元转到账户 2;
  • 事务 B 想把 账户 2 的 200 元转到账户 1;

如果两者在不同会话中执行操作顺序不当,就可能产生死锁。

3.2 具体代码演示

以下演示在两个不同终端或会话中分别执行事务 A 和事务 B。

会话 A(终端 1)

-- 会话 A
USE test_deadlock;

START TRANSACTION;
-- Step A1: 锁定 accounts id=1 行
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- (模拟业务处理延迟)
-- DO SLEEP or 等待会话 B 先执行

-- Step A2: 尝试锁定 accounts id=2 行
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;

COMMIT;

会话 B(终端 2)

-- 会话 B
USE test_deadlock;

START TRANSACTION;
-- Step B1: 锁定 accounts id=2 行
SELECT * FROM accounts WHERE id = 2 FOR UPDATE;
-- (模拟业务处理延迟)
-- DO SLEEP or 等待会话 A 已经执行第 A1 步

-- Step B2: 尝试锁定 accounts id=1 行
UPDATE accounts SET balance = balance - 200 WHERE id = 2;
UPDATE accounts SET balance = balance + 200 WHERE id = 1;

COMMIT;

并发执行顺序

  1. 会话 A 执行 SELECT * FROM accounts WHERE id = 1 FOR UPDATE;,锁住 id=1 的记录。
  2. 会话 B 执行 SELECT * FROM accounts WHERE id = 2 FOR UPDATE;,锁住 id=2 的记录。
  3. 会话 A 继续到 UPDATE ... WHERE id = 2;,此时需要锁住 id=2,但已被会话 B 锁住,A 被阻塞,等待 B 释放锁。
  4. 会话 B 继续到 UPDATE ... WHERE id = 1;,此时需要锁住 id=1,但已被会话 A 锁住,B 被阻塞,等待 A 释放锁。

此时 A 等待 B,B 等待 A,形成等待环,InnoDB 将检测到死锁。

3.3 ASCII 图解(记录锁交叉)

   +-------------------------+            +-------------------------+
   |        会话 A           |            |        会话 B           |
   |-------------------------|            |-------------------------|
   | START TRANSACTION;      |            | START TRANSACTION;      |
   | FOR UPDATE id=1 --------|------------>                         |
   | (锁住 Record(1) )       |            | FOR UPDATE id=2 --------|------------>
   |                         |            | (锁住 Record(2) )       |
   | 更新 id=1               |            |                         |
   | 尝试锁 id=2 <-----------|------------| UPDATE id=2             |
   |                         |            | 尝试锁 id=1 <-----------|
   +-------------------------+            +-------------------------+
          ↑等待 B释放 id=2                       ↑等待 A释放 id=1
          │                                         │
          └────────────── 死锁环路 ────────────────┘
  • Record(1)Record(2) 分别表示两条记录的行锁。
  • 互相等待对方持有的记录锁,从而形成死锁。

3.4 InnoDB 死锁检测与回滚

当 InnoDB 检测到这样的等待环时,会从以下两方面做处理:

  1. 选择牺牲者:InnoDB 会根据“回滚成本”(例如修改行数、加锁深度等),选择其中一个事务作为“死锁受害者”进行回滚(默认一般回滚后执行 SQL 的事务)。例如,可能是会话 B 被回滚。
  2. 通知客户端:被回滚的事务会返回类似如下错误:

    ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

    应用收到后需要代码层面捕获此错误,并重试事务或采取补偿措施。


4. 示例二:基于范围查询的间隙锁死锁

除了记录锁互相等待,间隙锁与临键锁也可能导致死锁,尤其在多个事务对同一范围的插入/更新产生冲突时。下面演示一个“基于范围插入”的死锁场景。

4.1 场景描述

继续使用表 accounts,在表中只关注 id 列作索引。现在有两个事务:

  • 事务 A 想插入 id=25
  • 事务 B 想插入 id=15
    但它们都使用 SELECT ... FOR UPDATE 预先锁定了一段范围,导致互相阻塞。

4.2 具体代码演示

会话 A(终端 1)

-- 会话 A
USE test_deadlock;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;

-- A1: 对 id BETWEEN 20 AND 30 范围加临键锁
SELECT * FROM accounts WHERE id BETWEEN 20 AND 30 FOR UPDATE;
-- 锁定 id=20,30 记录及 (20,30) 间隙

-- A2: 尝试插入 id=25
INSERT INTO accounts (id,balance) VALUES (25, 500);
-- 会被阻塞,因 (20,30) 间隙已被锁定

会话 B(终端 2)

-- 会话 B
USE test_deadlock;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;

-- B1: 对 id BETWEEN 10 AND 20 范围加临键锁
SELECT * FROM accounts WHERE id BETWEEN 10 AND 20 FOR UPDATE;
-- 锁定 id=10,20 记录及 (10,20) 间隙

-- B2: 尝试插入 id=15
INSERT INTO accounts (id,balance) VALUES (15, 700);
-- 会被阻塞,因 (10,20) 间隙已被锁定

并发执行顺序

  1. 会话 A 执行 SELECT ... WHERE id BETWEEN 20 AND 30 FOR UPDATE;

    • 锁定 (20,30) 区间的间隙,以及边界记录 id=20id=30
  2. 会话 B 执行 SELECT ... WHERE id BETWEEN 10 AND 20 FOR UPDATE;

    • 锁定 (10,20) 区间的间隙,以及边界记录 id=10(假设存在)和 id=20。此时 id=20 已被会话 A 锁定,会话 B 等待会话 A 释放 id=20 的记录锁。
  3. 会话 A 继续执行 INSERT INTO ... id=25,因 (20,30) 区间被会话 A 自己锁,但这里只是自己事务,不冲突;实际上插入也会请求 (20,30) 区间的插入许可,因它已经把 (20,30) 锁住,允许自己插入,所以 A 的 INSERT 可以执行成功。

    • 插入完成后,A 执行 COMMIT,释放对 (20,30) 的锁。
  4. 会话 B 仍在等待 id=20 的记录锁,一旦 A 提交,B 获得 id=20 锁,然后尝试 INSERT id=15,此时 (10,20) 已被 B 自己锁,允许插入,继续执行成功。
注意:严格地说,此示例并未形成死锁环,因为会话 A 和会话 B 争用的资源并不完全互为环路。要演示真正的间隙锁死锁,需要双方同时持有对方欲插入区间的部分锁。下面再补充一个更典型的例子。

4.3 典型间隙锁死锁示例

假设初始表中有 id=10,30,我们准备两个事务,分别锁两个相邻区间再尝试插入对方区间的值,形成死锁。

初始数据

DELETE FROM accounts;
INSERT INTO accounts (id,balance) VALUES (10,1000),(30,1000);

此时,索引节点为:

10  (10,30)  30

会话 A

-- 会话 A
USE test_deadlock;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;

-- A1: 锁定范围 (10,30),即 SELECT id BETWEEN 10 AND 30
SELECT * FROM accounts WHERE id BETWEEN 10 AND 30 FOR UPDATE;
-- 会对 id=10,30 加记录锁,对 (10,30) 加间隙锁
-- 锁定如下:
-- [10:X]  [(10,30):LOCKED]  [30:X]

会话 B

-- 会话 B
USE test_deadlock;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;

-- B1: 锁定范围 (10,30),但使用反向范围,比如 id > 20 AND id < 40
SELECT * FROM accounts WHERE id > 20 AND id < 40 FOR UPDATE;
-- 会对 id=30 加记录锁,对 (20,30) 与 (30,40) 加间隙锁
-- 其中 (20,30) 属于 (10,30) 的子区间,会与 A 的间隙锁冲突吗?
-- 先看效果:B 尝试锁定时,发现 id=30 已被 A 锁住,B 等待

到此,B 已经无法获得对 id=30 的记录锁,需要等待 A 提交或回滚。

接着,A 尝试插入会话 B 想插入的记录:

-- 会话 A 继续
INSERT INTO accounts (id,balance) VALUES (20,500);
-- 由于 (10,30) 区间被 A 自己锁定,允许插入 20
-- 执行成功后,A 提交
COMMIT;

此时,B 获得 id=30 记录锁,再进入 INSERT 步骤:

-- 会话 B 继续
INSERT INTO accounts (id,balance) VALUES (25,400);
-- B 已锁定 (20,30) 区间,允许插入 25
COMMIT;

依然没有死锁。要让死锁真正形成,需要两个事务同时锁定相互重叠、但方向相反的区间,并都在等待对方锁释放。以下是一个可以复现间隙锁死锁的更精确示例:

会话 A(典型死锁版)

-- 会话 A
USE test_deadlock;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;

-- A1: 锁定 id BETWEEN 10 AND 20(虽然 id=20 不存在,仍会对间隙 (10,20) 加锁)
SELECT * FROM accounts WHERE id BETWEEN 10 AND 20 FOR UPDATE;
-- 锁定 (10,20) 区间

会话 B

-- 会话 B
USE test_deadlock;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;

-- B1: 锁定 id BETWEEN 20 AND 30(id=20 不存在,id=30 存在,先锁录 id=30,再锁 (20,30))
SELECT * FROM accounts WHERE id BETWEEN 20 AND 30 FOR UPDATE;
-- 锁定 (20,30) 区间以及 id=30

此时锁状态如下(方括号代表锁定):

... [10]  [(10,20):A_LOCK]  (20)  [(20,30):B_LOCK]  [30] ...
  • 会话 A 拥有 (10,20) 间隙锁;会话 B 拥有 (20,30) 间隙锁和 id=30 记录锁。

会话 A 继续

-- 会话 A
-- 尝试插入 id=25,属于 (20,30) 区间;但 B 已经锁定 (20,30),A 阻塞
INSERT INTO accounts (id,balance) VALUES (25, 500);

A 等待 B 释放 (20,30) 间隙锁。

会话 B 继续

-- 会话 B
-- 尝试插入 id=15,属于 (10,20) 区间;但 A 已经锁定 (10,20),B 阻塞
INSERT INTO accounts (id,balance) VALUES (15, 700);

B 等待 A 释放 (10,20) 间隙锁。

此时 A 等待 B,B 等待 A,就形成了真正的环路死锁:

   A 拥有 (10,20)    B 拥有 (20,30)
     ↑   |               ↑   |
     |   ↓               |   ↓
  等待 (20,30)      等待 (10,20)

InnoDB 检测到这个环路后,会回滚成本较低的事务(假设回滚 A),并抛出死锁错误给会话 A,B 得到锁后自动继续执行。


5. MySQL 中检测与解决死锁

5.1 查看最近一次死锁信息

MySQL 的 InnoDB 会将死锁诊断信息记录在错误日志以及 SHOW ENGINE INNODB STATUS\G 输出的 “LATEST DETECTED DEADLOCK” 段中。

-- 执行后查看死锁信息
SHOW ENGINE INNODB STATUS\G

其中会包含类似如下的内容:

------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-10-10 12:00:00 0x7f8d9c0a4840
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 101, OS thread handle 140392312033024, query id 4567 localhost user update
INSERT INTO accounts (id,balance) VALUES (25,500)
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 123 page no 456 n bits 72 index `PRIMARY` of table `test_deadlock`.`accounts` trx id 12345 lock_mode X locks rec but not gap
*** (1) WALKS INTO LOCKS
RECORD LOCKS space id 123 page no 456 n bits 72 index `PRIMARY` of table `test_deadlock`.`accounts` trx id 12345 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
  0: len 4; hex 00000019; asc     ;;
  1: len 4; hex 00000001; asc     ;;
 
*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 1 sec selecting
mysql tables in use 1, locked 1
10 lock struct(s), heap size 1136, 5 row lock(s)
MySQL thread id 102, OS thread handle 140392312045136, query id 4568 localhost user update
INSERT INTO accounts (id,balance) VALUES (15,700)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 123 page no 456 n bits 72 index `PRIMARY` of table `test_deadlock`.`accounts` trx id 12346 lock_mode X locks rec but not gap
Record lock, heap no 1 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
  0: len 4; hex 0000000a; asc     ;;
  1: len 4; hex 00000001; asc     ;;
 
*** (2) WALKS INTO LOCKS
RECORD LOCKS space id 123 page no 456 n bits 72 index `PRIMARY` of table `test_deadlock`.`accounts` trx id 12346 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
  0: len 4; hex 00000019; asc     ;;
  1: len 4; hex 00000001; asc     ;;

*** WE ROLL BACK TRANSACTION (1)
  • 上述输出说明事务 12345(会话 A)与 12346(会话 B)之间存在死锁,InnoDB 选择回滚事务 (1)
  • 其中两者分别持有的锁与等待的锁都被列出,直观显示了死锁原因。

5.2 应用层面捕获死锁并重试

在应用代码中,遇到死锁错误时(错误码 1213, SQLState 40001),通常需捕获异常并重试该事务。例如,伪代码流程:

MAX_RETRY = 3
for i in 1..MAX_RETRY:
    START TRANSACTION
    try:
        执行业务逻辑更新/插入...
        COMMIT
        break  -- 成功退出循环
    except DeadlockError:  -- 捕获 “1213: Deadlock” 错误
        ROLLBACK
        if i == MAX_RETRY:
            raise  -- 超过重试次数,抛出错误
        else:
            # 等待随机短延迟后重试,防止活锁
            sleep(random small milliseconds)
    except OtherError:
        ROLLBACK
        raise  -- 其他错误直接抛出
  • 重试时可加随机延迟(“退避”机制),降低并发冲突概率。
  • 在设计高并发事务逻辑时,应尽量简化事务提交前所持锁的数量与时长,减少死锁概率。

6. 实战演练:多种死锁场景汇总

除了上面两个常见场景,还有以下几种死锁容易出现的场景,建议在开发时多加注意。

6.1 场景一:更新同一张表的两条不同行,顺序不同导致死锁

-- 初始数据
DELETE FROM accounts;
INSERT INTO accounts (id, balance) VALUES (100,1000),(200,2000);

-- 会话 A
START TRANSACTION;
SELECT * FROM accounts WHERE id = 100 FOR UPDATE;
-- (等待 B1 不释放时继续)
UPDATE accounts SET balance = balance - 100 WHERE id = 200;

-- 会话 B
START TRANSACTION;
SELECT * FROM accounts WHERE id = 200 FOR UPDATE;
-- (等待 A1 不释放时继续)
UPDATE accounts SET balance = balance - 200 WHERE id = 100;

-- 此时相互等待,形成死锁

要点:两条事务对同一两条记录加锁顺序不同,导致环路等待。

6.2 场景二:插入唯一索引键值导致死锁

假设表 users 有唯一索引 uname,存在 ('alice')('bob')

CREATE TABLE users (
  id INT PRIMARY KEY AUTO_INCREMENT,
  uname VARCHAR(20) UNIQUE,
  active TINYINT(1)
) ENGINE=InnoDB;

INSERT INTO users (uname,active) VALUES ('alice',1),('bob',1);
  • 会话 A 执行:

    START TRANSACTION;
    SELECT * FROM users WHERE uname BETWEEN 'a' AND 'c' FOR UPDATE;
    -- 锁定 (alice,bob) 的临键锁与间隙锁
  • 会话 B 执行:

    START TRANSACTION;
    SELECT * FROM users WHERE uname BETWEEN 'b' AND 'd' FOR UPDATE;
    -- 锁定 (bob) 的临键锁与相应间隙

此时两者都锁住了对方需要插入某个唯一值的间隙,如果接着插入新的 uname 值就可能产生死锁。具体细节类似前面间隙锁演示。

6.3 场景三:插入与更新同时对间隙锁产生冲突

假设表中只有 id=100,200 两条记录,应用中一个事务 A 要更新 id=100 并插入 id=150,另一个事务 B 要更新 id=200 并插入 id=150,在 REPEATABLE READ 下也会死锁。

-- 会话 A
START TRANSACTION;
SELECT * FROM accounts WHERE id = 100 FOR UPDATE;  -- 锁记录100并加 (100,∞) 间隙锁
INSERT INTO accounts (id,balance) VALUES (150,500);  -- 需要锁 (100,200) 区间

-- 会话 B
START TRANSACTION;
SELECT * FROM accounts WHERE id = 200 FOR UPDATE;  -- 锁记录200并加 (100,200),(200,∞) 间隙锁
INSERT INTO accounts (id,balance) VALUES (150,400);  -- 需要锁 (100,200) 区间,等待A
  • 事务 A 已锁住 (100,∞),要插入 150 时需要 (100,150)(150,200) 区间;
  • 事务 B 已锁住 (100,200) 的一部分间隙;
  • 双方等待对方释放,产生死锁。

7. 预防与解决死锁的实用技巧

  1. 统一访问顺序

    • 尽量让并发事务对同一张表的多行加锁时,按照相同顺序(如按照主键升序)访问,避免并发事务交叉加锁。
  2. 缩短事务时长

    • 只在必要的业务逻辑中才开启事务,尽量减少事务内的查询或计算时间,快速提交并释放锁。
  3. 使用较低隔离级别

    • 如果业务能容忍“幻读”,可将隔离级别设为 READ COMMITTED,此时 InnoDB 不会对范围查询加间隙锁,减少死锁可能性。
    SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
  4. 合理设计索引与 WHERE 条件

    • 避免无索引的全表扫描式更新或范围查询,因为此时 InnoDB 会对整张表或大范围加锁,增加死锁风险。
    • 对常用查询字段加索引,保证加锁粒度尽量小。
  5. 捕获死锁并自动重试

    • 在应用层捕获死锁错误(MySQL 错误码 1213),并简单重试。大多数死锁都是“概率性”问题,通过重试便能成功。
    • 可为核心业务逻辑设置重试上限,避免持续重试导致响应延迟。
  6. 监控死锁频率

    • 可查询系统状态变量 SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks'; 查看死锁总次数。
    • 将该指标纳入监控告警,一旦死锁频繁发生,说明并发冲突严重,需要优化业务或索引设计。

8. 小结

  • 死锁产生的本质:并发事务因不一致的加锁顺序或重叠的间隙加锁,形成环路等待,InnoDB 检测到后会回滚其中一个事务。
  • 典型诱因:两个或多个事务交叉对相同或相邻记录加锁(记录锁、间隙锁、临键锁),并尝试获取已被对方持有的锁,造成等待环。
  • 解决思路

    1. 统一加锁顺序:保证多个事务以相同顺序访问相同表的行。
    2. 减少并发冲突范围:尽量使用精确的索引条件,减少范围锁的使用。
    3. 缩短事务时长:让加锁时间尽量短。
    4. 使用较低隔离级别:在可接受的业务场景下采用 READ COMMITTED,避开间隙锁。
    5. 捕获并重试:应用层捕获死锁错误并自动重试,减轻业务感知影响。

通过本文的代码示例和 ASCII 图解,你应能直观地看到 MySQL InnoDB 中不同锁类型是如何互相等待、形成死锁环的,也清楚地了解如何检测和优化以降低死锁概率。

评论已关闭

推荐阅读

DDPG 模型解析,附Pytorch完整代码
2024年11月24日
DQN 模型解析,附Pytorch完整代码
2024年11月24日
AIGC实战——Transformer模型
2024年12月01日
Socket TCP 和 UDP 编程基础(Python)
2024年11月30日
python , tcp , udp
如何使用 ChatGPT 进行学术润色?你需要这些指令
2024年12月01日
AI
最新 Python 调用 OpenAi 详细教程实现问答、图像合成、图像理解、语音合成、语音识别(详细教程)
2024年11月24日
ChatGPT 和 DALL·E 2 配合生成故事绘本
2024年12月01日
omegaconf,一个超强的 Python 库!
2024年11月24日
【视觉AIGC识别】误差特征、人脸伪造检测、其他类型假图检测
2024年12月01日
[超级详细]如何在深度学习训练模型过程中使用 GPU 加速
2024年11月29日
Python 物理引擎pymunk最完整教程
2024年11月27日
MediaPipe 人体姿态与手指关键点检测教程
2024年11月27日
深入了解 Taipy:Python 打造 Web 应用的全面教程
2024年11月26日
基于Transformer的时间序列预测模型
2024年11月25日
Python在金融大数据分析中的AI应用(股价分析、量化交易)实战
2024年11月25日
AIGC Gradio系列学习教程之Components
2024年12月01日
Python3 `asyncio` — 异步 I/O,事件循环和并发工具
2024年11月30日
llama-factory SFT系列教程:大模型在自定义数据集 LoRA 训练与部署
2024年12月01日
Python 多线程和多进程用法
2024年11月24日
Python socket详解,全网最全教程
2024年11月27日