InnoDB 锁机制和事务管理介绍

引言

在数据库性能优化中,锁机制与事务管理是两个关键环节。锁的合理使用与事务的有效管理不仅能提升并发性能,还能避免数据不一致与死锁等问题。
本文旨在分享InnoDB的锁机制与事务管理的知识,不同的事务隔离级别下innodb的解决方案,帮助研发人员更好地理解和应用这些概念,从而优化MySQL数据库的整体性能。

ps:不同版本,规则可能有差别;本文验证版本:8.0.28 MySQL Community Server。

思考题

抛几个问题,先思考下;使用innodb引擎+RR隔离级别。

初始化信息:

-- 问题一、二
drop table if exists layout_test;
drop PROCEDURE if exists layout_test_mock;

create table layout_test (
    `col1` int NOT NULL AUTO_INCREMENT,
    `col2` int not null,
    primary key(`col1`),
    key(`col2`)
) engine = innodb AUTO_INCREMENT = 0;

DELIMITER $$  
CREATE PROCEDURE layout_test_mock()  
BEGIN  
    DECLARE i INT DEFAULT 0;  
    WHILE i < 10000 DO  
        INSERT INTO layout_test (col2) VALUES (FLOOR(RAND() * 100) + 1);  
        SET i = i + 1;  
    END WHILE;  
END$$  
  
DELIMITER ;
call layout_test_mock();
OPTIMIZE table layout_test;

-- 问题三
DROP TABLE IF EXISTS test_lock;
CREATE TABLE `test_lock` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `b` INT DEFAULT NULL,
  `c` INT DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_1` (`b`)
) ENGINE=INNODB AUTO_INCREMENT=0;
INSERT INTO test_lock VALUES (50, 50, 50), (55, 55, 55), (60, 60, 60), (62, 62, 62), (65, 65, 65), (66, 66, 66);

问题一

mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT * FROM layout_test WHERE col2 = 37 limit 5;
+------+------+
| col1 | col2 |
+------+------+
|  343 |   37 |
|  411 |   37 |
|  479 |   37 |
|  575 |   37 |
|  641 |   37 |
+------+------+
5 rows in set (0.00 sec)

mysql> update layout_test set col2 = col2+2 where col2 = 37;
Query OK, 0 rows affected (0.00 sec)
Rows matched: 0  Changed: 0  Warnings: 0

mysql> SELECT * FROM layout_test WHERE col2 = 37 limit 5;
+------+------+
| col1 | col2 |
+------+------+
|  343 |   37 |
|  411 |   37 |
|  479 |   37 |
|  575 |   37 |
|  641 |   37 |
+------+------+
5 rows in set (0.00 sec)

问题:为什么update更新的是0行,如何来构造这个场景?

问题二

mysql> START TRANSACTION WITH CONSISTENT SNAPSHOT;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT * FROM layout_test WHERE col1 = 1;
+------+--------+
| col1 | col2   |
+------+--------+
|    1 | 1|
+------+--------+
1 row in set (8.24 sec)

mysql> SELECT * FROM layout_test WHERE col1 = 1 LOCK IN SHARE MODE;
+------+---------+
| col1 | col2    |
+------+---------+
|    1 | 1000001 |
+------+---------+
1 row in set (0.00 sec)

问题:为什么加锁查询比不加锁快很多,如何来构造这个场景?

问题三

-- session A
BEGIN;
SELECT * FROM test_lock WHERE b=55 FOR UPDATE;

先执行A,再执行B,为什么有的成功,有的被阻塞?

-- session B
BEGIN;

INSERT INTO test_lock VALUES(40, 40, 40); -- ok
INSERT INTO test_lock VALUES(45, 50, 45); -- ok
INSERT INTO test_lock VALUES(51, 50, 51); -- blocked
INSERT INTO test_lock VALUES(52, 52, 52); -- blocked
INSERT INTO test_lock VALUES(57, 57, 57); -- blocked
INSERT INTO test_lock VALUES(58, 60, 58); -- blocked
INSERT INTO test_lock VALUES(61, 60, 61); -- ok

事务隔离级别介绍

在讨论锁机制之前,我们需要了解 MySQL InnoDB 提供的四种事务隔离级别,每种隔离级别在性能与数据一致性之间做出了不同的权衡。根据应用需求选择合适的隔离级别是数据库优化的关键。这些隔离级别通过不同的锁机制和多版本并发控制(MVCC)来实现。

  • 读未提交(Read Uncommitted):

    • 允许事务读取尚未提交的数据变更,即可能读取到“脏数据”。

    • 适用场景:对数据一致性要求不高的场景,如某些实时数据分析。

    • 注意事项:可能会读取到“脏数据”,即未提交的数据。

  • 读已提交(Read Committed):

    • 允许事务在每次读取数据时都获取最新的提交数据,即只能读取到已经提交的数据。

    • 适用场景:大多数业务场景,保证事务在提交时读取到最新的数据。

    • 注意事项:在同一个事务中,多次读取同一数据可能得到不同的结果,即“不可重复读”。

  • 可重复读(Repeatable Read):

    • 确保在同一个事务中多次读取同样记录的结果是一致的,即在这个事务执行期间,其他事务的更新操作不会影响到本事务的读取结果。

    • 适用场景:需要保证事务在整个执行过程中读取到一致数据的场景,如银行转账。

    • 注意事项:SQL92标准规范中存在幻读问题,InnoDB通过间隙锁在RR隔离级别下解决了幻读问题。

  • 串行化(Serializable):

    • 最高的隔离级别,事务串行执行,即事务只能一个接一个地进行,不能并发执行。

    • 适用场景:对数据一致性要求极高的场景。

    • 注意事项:性能开销大,因为事务只能串行执行。

MVCC与版本链(undo log)

MVCC:Multi-Version Concurrency Control
多版本并发控制机制,目的是提升数据库的并发性能;
它通过在同一行数据上维护多个版本的快照(版本链)来避免读写冲突,从而提高并发性。

版本链(undo log)

版本链通过undo Log实现。当事务对数据进行修改时,InnoDB会在undo Log中保存该数据的旧版本。
undo Log是一个逻辑日志,在事务提交或回滚后,这些旧版本数据会根据需要被保留或删除。


以下图为例,每一次修改,都会产生一个版本;在读取的时候,根据事务的可见性规则找到匹配的版本数据。

在这里插入图片描述


每一行数据中有两个隐藏列,用于实现版本管理; trx_id: 它是最近一次插入、更新或删除该行的事务标识符。ps:删除在innodb内部被视为更新; roll_pointer: 指向该行上一个版本的指针(回滚指针),用于实现回滚操作,通过undo log实现。

在这里插入图片描述

相关概念

快照读(Consistent Nonlocking Reads):事务读取数据时,读取的是事务开始时的快照,而不是最新的数据;在不加锁的情况下读取数据,提高并发性;当只需要读取数据无需修改时使用快照读。
当前读(Locking Reads):读取的是最新的数据版本,并且会对读取的数据加锁。当需要确保读取的数据在事务处理过程中不会被其他事务修改时,使用当前读。如select … lock in share mode、update table set …, 执行时均使用当前读。
其中快照读就是基于版本链来实现的,在版本链上读取事务可见的数据版本。识别规则见后面的“事务的可见性规则”。

mysql官网手册相应概念介绍:
快照读
当前读

事务的可见性规则

当事务进行快照读操作时,会生成一个Read View,并记录未提交的事务id。然后,根据Read View和记录的隐藏字段(事务ID和回滚指针),系统可以找到对当前事务可见的数据版本。

  • 如果记录的事务ID小于Read View中的最小活跃事务ID,说明该记录在当前事务开始前就已经被提交了,因此该版本的数据对当前事务是可见的。

  • 如果记录的事务ID等于当前事务的事务ID,说明该记录是当前事务自己生成的,因此也是可见的。

  • 如果记录的事务ID大于Read View中的最大事务ID,说明该记录是在当前事务开始后生成的,因此不可见。

  • 如果记录的事务ID在Read View的活跃事务ID列表中,说明该记录是由尚未提交的事务生成的,因此也不可见。但如果不在列表中,且事务ID大于最小活跃事务ID但小于最大事务ID,则需要通过回滚指针找到旧版本的数据,并重复上述判断过程。
    在这里插入图片描述


show engine innodb status相关内容

在这里插入图片描述

参考资料

官网手册MVCC介绍
MVCC实现原理

innodb索引

锁,锁定的对象是索引(的叶子节点)。在介绍锁之前,介绍下索引相关的信息。
Innodb存储引擎的索引,使用B+树来存储索引信息;
包括聚簇索引(主键索引)和二级索引(普通索引),只有叶子节点存储数据;
其中主键索引的叶子节点存的是整行数据;非主键索引,存储的是主键的值。

B+树的结构

在这里插入图片描述

聚簇索引的结构

在这里插入图片描述

ps: 对应思考题中layout_test这个表结构的主键索引
聚簇索引,叶子节点,存储的是完整的数据行内容。

二级索引的结构

在这里插入图片描述

ps: 对应思考题中layout_test这个表结构的二级索引
二级索引,叶子节点存储的是主键。通过二级索引查找时,如果查询的目标列不在索引上,需要通过主键去聚簇索引上查询数据(回表的过程)。

参考资料

极客时间课程《MySQL实战45讲》-04|深入浅出索引(上)
同事xx分享的MYSQL的innodb索引原理

锁介绍

为什么需要锁

锁的主要目的是为了在并发环境下保证数据的一致性和完整性。尤其是在多用户同时操作数据库的情况下,锁机制可以防止数据被多个事务同时修改,避免数据不一致。

什么是锁

锁是数据库系统为了协调多个事务对同一数据的并发访问而引入的机制。锁分为共享锁和排他锁,前者允许多个事务同时读取数据,而后者则会阻止其他事务对数据的任何访问,直到锁被释放。

锁的类型

InnoDB 支持多种锁机制,包括行级锁、表级锁等。常见的锁类型有:

  • 行级锁:针对单行记录进行加锁,可以最大程度提高并发性能。
  • 表级锁:针对整个表进行加锁,虽然简单但会影响并发性能。

不同类型的锁适用于不同的场景,合理使用这些锁直接影响数据库的并发性能。
在这里插入图片描述

和事务隔离相关的,包括表级的意向锁,以及行级锁;
其中表级的意向锁,是为了减少锁减少,提高并发度;后面主要介绍行级锁的加锁机制。

加锁原则

在InnoDB中,锁的使用遵循以下原则:

  • 行锁优先:InnoDB尽量使用行锁,以提高并发性能。
  • 自动锁定:在执行SQL语句时,InnoDB会根据语句类型自动加锁,无需手动指定。
  • 最小范围锁定:InnoDB尽量减少锁的范围,避免对无关数据的锁定。

行级锁范围示意图

在这里插入图片描述

以上面的B+树为例,其对应的行级锁范围示例如下:
在这里插入图片描述


不同类型的锁在数据库管理中扮演着重要角色,它们与事务隔离级别紧密相关,共同决定了数据库操作的一致性和并发性能。接下来,我们将深入探讨InnoDB如何针对不同的事务隔离级别,利用锁机制来实现特定的数据保护策略。

不同隔离级别的解决方案

下面介绍innodb针对不同事务隔离级别的实现方案,重点介绍RR和RC。

RR的实现介绍

RR:repeatable read(可重复读),innodb在此基础上通过间隙锁解决了幻读的问题。


在同一个事务中,使用的是同一个快照,多次查询,查询到的是相同的内容;即不会增加行、减少行,同一行的内容也不会变更。
MVCC机制,保证了读的一致性;而锁机制,是为了保证写的一致性;同一个目标数据,同一个时刻(or时段),只允许有一个事务对其进行修改。
(回顾下前面的MVCC版本链,只允许有一个版本链)



先贴一份大牛丁奇总结的加锁规则(版本:5.x系列 <= 5.7.24,8.0系列 <= 8.0.13)
在这里插入图片描述

ps:在8.0.28验证时,第5条(bug)已修复;
来源

我的理解:加锁的目的,是事务本身想修改数据,或者不想让其他事务来修改数据;故加锁的范围,是想保护的数据可能会触达的范围。
以下图为例,如果插入index=57的记录,其必定落在(55、60)这个区间;
而插入index=65的记录,可能插入在左边间隙(62到65),也可能插入在右边间隙(65到66)。
在这里插入图片描述


结合自己的验证情况(8.0.28版本),对大牛总结的规则解读如下

  • 加锁的基本单位是next-key lock。左开右闭区间;
  • 查找过程中访问到的对象才会加锁;
  • 对目标数据可能会触达的范围会进行加锁;可触达的范围:(目标值左边的第一个记录值、目标值右边的第一个记录值];由于加锁的基本单位是next-key lock,故为左开右闭区间;
  • 可触达范围识别优化
    • 对于唯一索引,由于值不可重复特性,在边界条件判断时,不会跨边界值;如果包含等于号,包括边界值,否则不包含边界值;
    • 而非唯一索引,由于值可重复特性,在边界条件判断时,可能会跨边界值;如果包含等于号,需要跨过边界值,到下一个不满足条件的记录,否则不会跨过边界值。但是否包含边界值,在范围查询和等值查询时表现不一样。范围查询时,按next-key lock加锁,而等值查询会退化为gap lock。

有点费解,可以结合后面的案例进行理解。

唯一索引案例

实验表信息如下,后面的案例都基于这份数据;

DROP TABLE IF EXISTS test_lock;
CREATE TABLE `test_lock` (
  `id` INT NOT NULL AUTO_INCREMENT,
  `b` INT DEFAULT NULL,
  `c` INT DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_1` (`b`)
) ENGINE=INNODB AUTO_INCREMENT=0;
INSERT INTO test_lock VALUES (50, 50, 50), (55, 55, 55), (60, 60, 60), (62, 62, 62), (65, 65, 65), (66, 66, 66);

数据如下:
在这里插入图片描述

等值查询

场景一:给存在的记录加锁

BEGIN;
SELECT * FROM test_lock WHERE id = 55 LOCK IN SHARE MODE;

-- 查询加锁信息
SELECT p.engine_transaction_id, p.thread_id, p.event_id, OBJECT_INSTANCE_BEGIN, p.object_schema, p.object_name, p.index_name,  lock_type, lock_mode, lock_status, lock_data  
FROM performance_schema.`data_locks`  p
order by engine_transaction_id, thread_id, index_name, lock_type, lock_data;

针对访问的主键索引加锁,给id=55这一行添加了记录锁 (REC_NOT_GAP)
在这里插入图片描述

加锁说明:
唯一索引,由于其不可重复的特性,不会再出现新的id等于55的记录,故只需要锁定自身;

换一个角度,可以将条件转换为 id >=55 and id <=55来理解;
左边界为55,包含等于号,故包括边界值,但不会涉及55左边的间隙;
右边界为55,包含等于号,故包括边界值,但不会涉及55右边的间隙;

场景二:给不存在的记录加锁

BEGIN;
SELECT * FROM test_lock WHERE id = 56 LOCK IN SHARE MODE;

加锁信息如下,给(55,60)这个间隙进行了加锁;
在这里插入图片描述

在这里插入图片描述

加锁说明:
左边界为55,不包含等于号,故不包括边界值;
右边界为60,不包含等于号,故不包括边界值;

范围查询
BEGIN;
SELECT * FROM test_lock WHERE id > 52 AND id < 61 LOCK IN SHARE MODE;

加锁信息如下,范围:(50, 55]、(55, 60]、(60, 62)
在这里插入图片描述

在这里插入图片描述

非唯一索引案例

等值查询

场景一,给存在的记录加锁(不回表)

BEGIN;
SELECT id FROM test_lock WHERE b = 55 LOCK IN SHARE MODE;

针对访问的普通索引加锁,加锁范围:(50、55]、(55、60);
在这里插入图片描述

加锁过程说明:
非唯一索引,值可以重复,可能会出现新的等于55的记录,故需要锁住左右间隙;

换一个角度,可以将条件转换为 id >=55 and id <=55来理解;
左边界为55,包含等于号,故包括边界值,而55左边的间隙也可能会出现55的记录,故这个范围也需要加锁;
右边界为55,包含等于号,故包括边界值,而55右边的间隙也可能会出现55的记录,故这个范围也需要加锁;
加锁的单位为next-key lock,而等值查询进行了优化,最后一个不满足条件的记录退化为间隙锁;
最终加锁为:(50、55](临键锁)、(55、60)(间隙锁)

场景二,给不存在的记录加锁

BEGIN;
SELECT id FROM test_lock WHERE b = 56 LOCK IN SHARE MODE;

加锁范围:(55, 60)
在这里插入图片描述

假设b=56这条记录存在,其锁定的范围和场景一是一样的;锁定左右边界可触达的范围;

场景三,给存在的记录加锁(回表)

BEGIN;
SELECT * FROM test_lock WHERE b = 55 LOCK IN SHARE MODE;

在这里插入图片描述

和场景一对比,由于需要回表查询数据,会访问到主键索引中id=55的这条记录,故对其也进行了加锁;

范围查询

场景一(闭区间)

BEGIN;
SELECT id FROM test_lock WHERE b >= 55 AND b <= 62 LOCK IN SHARE MODE;

加锁范围:(50, 55]、(55, 60]、(60, 62]、(62、65];
在这里插入图片描述

加锁过程说明:

  1. 左边界b=55,涉及的范围为:(50、60];
  2. 右边界b=62,涉及的范围为:(60、65];
  3. 左右边界所包含的范围全部加锁;
    故最终范围为:(50、65]。
    对于边界的识别,按等值查询加锁范围来理解;但右边界未退化为间隙锁。

场景二(左开右闭区间)

BEGIN;
SELECT id FROM test_lock WHERE b > 55 AND b <= 62 LOCK IN SHARE MODE;

加锁范围:(55, 60]、(60, 62]、(62、65];
在这里插入图片描述

加锁过程说明:

  1. 左边界b>55,涉及的范围为:(55、60];
  2. 右边界b=62,涉及的范围为:(60、65];
  3. 左右边界所包含的范围全部加锁;
    故最终范围为:(55、65]。
    b > 55的场景,可以按b=55.1来理解加锁范围(假设最小精度为0.1)。

案例三(开区间):

BEGIN;
SELECT id FROM test_lock WHERE b > 55 AND b < 62 LOCK IN SHARE MODE;

加锁范围:(55, 60]、(60, 62];
在这里插入图片描述

无索引

BEGIN;
SELECT * FROM test_lock WHERE c = 55 LOCK IN SHARE MODE;

需要全表扫描,故所有记录(包括间隙)均加锁; 因为任何一个地方都可能插入c=55的记录(或修改、删除);
在这里插入图片描述

ps:由于没有索引,会按照主键的顺序来扫描数据(通过主键遍历),故对主键进行加锁;

limit场景说明

以无索引场景为例,查看添加limit后加锁范围的变化

BEGIN;
SELECT * FROM test_lock WHERE c = 55 LIMIT 1 LOCK IN SHARE MODE;

访问到第一个满足记录的行时终止扫描,因此后面的范围不会加锁。如果不存在c=55的记录,则全部加锁;limit可以有效的缩小锁范围,和是否走索引无关。
业务上,如果添加limit不影响业务含义,建议添加limit。
在这里插入图片描述

RC的实现介绍

RC隔离级别:读已提交,不支持可重复读;


和RR隔离级别相比,RC隔离级别加锁的基本单位是记录锁(record lock),不对间隙进行加锁,且只对已存在的Record进行加锁; 另外,两者在锁的行为和可见性上也存在差异。在RC隔离级别下,每次查询都会生成新的快照,且只对满足条件的记录加锁,不满足条件的记录会立即释放锁,无需等待事务结束。这有助于提高并发性能但可能导致不可重复读。

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

唯一索引

等值查询

场景一,给存在的记录加锁;

BEGIN;
SELECT * FROM test_lock WHERE id = 55 LOCK IN SHARE MODE;

加锁范围和RR下相同;
在这里插入图片描述

场景二,给不存在的记录加锁;

BEGIN;
SELECT * FROM test_lock WHERE id = 54 LOCK IN SHARE MODE;

加锁信息如下,新开一个事务,可以插入id=54的记录;
在这里插入图片描述

范围查询
BEGIN;
SELECT * FROM test_lock WHERE id > 52 AND id < 61 LOCK IN SHARE MODE;

只对访问到的行添加记录锁
在这里插入图片描述

非唯一索引

加锁规则和唯一索引相同

无索引

BEGIN;
SELECT * FROM test_lock WHERE c = 55 LOCK IN SHARE MODE;

只对满足条件的行,添加记录锁;
在这里插入图片描述

ps:这个是结果,非执行过程中的加锁情况;
执行过程中会对访问到的所有行进行加锁,RC隔离级别下,不满足条件的行会立刻释放锁,无需等待事务结束;

session A(先执行):

BEGIN;
SELECT * FROM test_lock WHERE c = 50 FOR UPDATE;

session B:

BEGIN;
SELECT * FROM test_lock WHERE c = 55 LOCK IN SHARE MODE;

session B会出现锁等待;
在这里插入图片描述

读未提交、串行化介绍

读未提交,直接读取最新的记录;无MVCC机制,无锁机制(也无需锁);
串行化,每个事务都串行执行。

思考题解答

结合前面介绍的内容,建议先自行思考解答下,能更好的加深理解。
InnoDB锁机制和事务管理介绍_案例解答

优化建议

  • 创建合适的索引
    • 从前面的案例可以看到,走索引涉及的范围远小于全表扫描;走索引性能的性能开销,远低于全表扫描;
  • 避免大事务
    • 大事务,维度
      • 持续时间长
      • 涉及数据量大
      • 锁的范围大、持续时间长
      • 复杂性高
    • 影响(部分举例)
      • 持续时间长:需要一直保留快照,增加了开销;
        • mysql官网说明:建议您定期提交事务,包括仅发出一致读取的事务。否则,InnoDB无法从更新撤销日志中丢弃数据,并且回滚段可能会变得过大,从而填满其所在的撤销表空间(原文:It is recommend that you commit transactions regularly, including transactions that issue only consistent reads. Otherwise, InnoDB cannot discard data from the update undo logs, and the rollback segment may grow too big, filling up the undo tablespace in which it resides)
      • 锁的范围大、持有时间长:加剧了锁竞争
      • 数据量大:增加了IO开销
  • 事务中加锁一般原则(访问时加锁,事务结束时释放锁)
    • 竞争小的目标优先操作
    • 竞争大的目标最后操作
    • 按相同的顺序对资源加锁,避免死锁

避免大事务的一个示例,如需要清理7天之前的业务日志(大数据量)。

  • 反例
    • delete from table where createTime <= (now -7d);
    • 涉及的数据范围很大,锁的范围很大
  • 建议:
    • 思路:拆分成小事务来执行
      • idea1:
        • step1: select id from table where createTime <= (now -7d) order by id desc limit 1;
        • step2: delete from table where id < step1.id limit step;
        • step3: 循环多次执行step2,直至完成;中间按需设置休眠时间;
      • idea2:
        • delete from table where createTime <= (now -7d) limit step; (通过limit限制加锁范围)
        • 循环执行上面脚本,直至完成;中间按需设置休眠时间;
      • ps: 如果createTime无索引,可以考虑根据id来排序查询然后删除(结合表结构、业务特征考虑)

前瞻与展望

MySQL社区不断发展,锁机制与事务管理也在不断优化与完善。未来,随着硬件性能的提升和新技术的引入,锁机制可能会进一步优化,以更好地支持高并发环境下的数据库应用。

结束语

为什么要分享这篇文章?
一是我们线上环境,经常能看到死锁。希望能通过本文了解innodb的锁机制,学会去排查、减少死锁;
二是表设计,存在无索引或者索引设计不合理。希望大家能意识的索引、好索引的价值。

在高并发环境下,数据库性能的提升往往依赖于对锁的精细管理和对索引的合理设计。希望有所助益!

如有错误之处,恳请指正,感谢!

参考资料

  • 14
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值