【数据库基础】2. 索引

本文详细介绍了数据库索引的基础知识,包括索引分类、数据结构、主索引与辅助索引、稠密与稀疏索引。重点讨论了B+树索引的原理、结构、查询、插入、删除和优化方法,并对比了B+树与散列索引的优缺点。此外,还提到了动态散列和静态散列的实现方式及其特点。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >


索引概述


索引(index)是一些能够帮助数据库系统高效查询数据的数据结构的统称

许多对表的查询只涉及表中很少的行,如找出学号是 114514 的学生的总学分,如果不借助索引,数据库系统就要读取表中每一行并检查其学号属性是否为 114514 ,这种方法无疑是低效的,而借助索引,我们可以通过某些方法直接定位到学号属性为 114514 的行

用于定位到待查询行的属性或属性集称作搜索码(search key)

索引的分类

数据结构

按数据结构分类,可以分为 B 树索引、B+ 树索引、散列索引等

主索引和辅助索引

被索引的表以文件的形式存放在磁盘中,如果文件中该表的行按某个搜索码指定的顺序排序,就把该搜索码对应的索引称作主索引(primary index),又叫聚集索引,其它搜索码对应的索引称作辅助索引(secondary index),又叫非聚集索引

通常情况下,一个表最多只有一个主索引,而辅助索引的个数没有限制

按主索引顺序对表进行顺序扫描要比按辅助索引顺序快很多,因为按主索引顺序扫描,需要访问的物理地址几乎是连续的

稠密索引和稀疏索引

如果表中在搜索码上的每一个值都对应一个索引项,则该索引称作稠密索引

如果表中在搜索码上只有部分值对应索引项,则该索引称作稀疏索引

辅助索引不能是稀疏索引,因为这样会导致没有索引项对应的搜索码值无法被定位到,而主索引如果是稀疏索引,可以先通过索引项定位到最接近待查询的搜索码值的行,然后从这行起在文件中逐行查找,直到找到待查询的搜索码值

索引的优缺点

优点
  1. 加快查询数据的速度
  2. 加快对表上唯一性约束检查的速度
  3. 加快表连接操作的速度
缺点
  1. 索引需要占用额外的空间
  2. 减慢了对表插入、删除、修改数据的速度,因为在执行这些操作时需要同时维护索引

B+ 树索引


B+ 树是一种多叉平衡树,和其它平衡树一样,B+ 树能够高效地增删数据和查询数据

用 B+ 树而不是二叉平衡树来实现索引的原因是:一张大表上的索引,其占用空间也很大,需要存放在磁盘而非内存中,多叉平衡树相比于二叉平衡树高度更低,一次操作所需要访问的节点数也更少,导致磁盘访问的次数更少,显然时间性能瓶颈在于磁盘访问耗时,因此 B+ 树相比于二叉平衡树,增删数据和查询数据更快

利用 B+ 树来维护搜索码值以及搜索码所在行的物理地址,我们就实现了 B+ 树索引

叶节点

B+ 树的叶节点结构如下:

P 1 P_1 P1 K 1 K_1 K1 P 2 P_2 P2 K 2 K_2 K2 ⋯ \cdots P n − 1 P_{n-1} Pn1 K n − 1 K_{n-1} Kn1 P n P_n Pn

其中 K 1 , K 2 , ⋯   , K n − 1 K_1,K_2, \cdots ,K_{n-1} K1,K2,,Kn1 n − 1 n-1 n1 个搜索码,且对于 i ∈ [ 1 , n − 2 ] i \in [1,n-2] i[1,n2] ,有 K i ≤ K i + 1 K_i \le K_{i+1} KiKi+1

对于 i ∈ [ 1 , n − 1 ] i \in [1,n-1] i[1,n1] P i P_i Pi 指向 K i K_i Ki 所在行的物理地址,而 P n P_n Pn 则指向 DFS 序顺序的下一个叶节点,对于按 DFS 序排序排在最后的叶节点,其 P n P_n Pn 指向按 DFS 序排序排在首位的叶节点,通常情况下,DFS 序越大的叶节点中的搜索码值越大

所有叶节点的高度相同,并且每个插入 B+ 树的搜索码,在所有叶节点中出现恰好一次

非叶节点

B+ 树的非叶节点结构和叶节点结构看起来一样, K 1 , K 2 , ⋯   , K n − 1 K_1,K_2, \cdots ,K_{n-1} K1,K2,,Kn1 仍是 n − 1 n-1 n1 个有序的搜索码,但 P 1 , P 2 , ⋯   , P n P_1,P_2, \cdots ,P_n P1,P2,,Pn 指向的东西不同

B+ 树的非叶节点中, P 1 , P 2 , ⋯   , P n P_1,P_2, \cdots ,P_n P1,P2,,Pn 都指向一个子节点, P 1 P_1 P1 指向的子节点中包含的搜索码都小于 K 1 K_1 K1 P n P_n Pn 指向的子节点中包含的搜索码都大于等于 K n − 1 K_{n-1} Kn1 ,而对于 i ∈ [ 2 , n − 1 ] i \in [2,n-1] i[2,n1] P i P_i Pi 指向的子节点中包含的搜索码都小于 K i K_i Ki ,大于等于 K i − 1 K_{i-1} Ki1

阶是 B+ 树的一个重要的属性,用来限定叶节点中搜索码值个数的最大最小值,以及非叶节点中指针个数的最大最小值

对于一个 m m m 阶的 B+ 树,其叶节点包含的搜索码个数最少为 ⌊ m − 1 2 ⌋ \lfloor \frac{m-1}{2} \rfloor 2m1 ,最多为 m − 1 m-1 m1 ,其非叶节点包含的指针个数最少为 ⌊ m 2 ⌋ \lfloor \frac{m}{2} \rfloor 2m ,最多为 m m m ,但根节点不受指针个数最少为 ⌊ m 2 ⌋ \lfloor \frac{m}{2} \rfloor 2m 的限制,根节点包含的指针个数最少为 2

查询

B+ 树的查询和其它平衡树类似,由于只有叶节点保存了搜索码对应的物理地址,因此查询会从根节点开始,一路移动到叶节点

如果需要查询搜索码在某个范围内的所有行,可以先定位到范围的下界,然后通过叶节点之间的指针,遍历到范围内的所有行

插入

首先可以通过一次查询,找到待插入记录的搜索码应该包含在哪个叶节点中,然后在该叶节点中添加搜索码和指针,如果添加之后叶节点搜索码个数未超过最大值,则插入完成

但问题在于,添加搜索码之后,叶节点搜索码个数可能超过最大值,也就是说设 B+ 树是 m m m 阶的,叶节点搜索码个数可能超过 m − 1 m-1 m1

这种情况下,就需要把这个叶节点分裂成两个,分裂出的第一个节点包含原节点前一半搜索码及对应的指针,第二个包含原节点后一半搜索码及对应的指针,分裂出的第一个节点的 P n P_n Pn 指向第二个节点,由于原节点搜索码个数超过 m − 1 m-1 m1 ,因此分裂出的两个节点的搜索码个数不小于 ⌊ m − 1 2 ⌋ \lfloor \frac{m-1}{2} \rfloor 2m1 ,也就是说分裂出的两个节点的搜索码个数满足限制条件

分裂出的两个节点的父节点,还是原节点的父节点,也就是说父节点多出来了一个子节点,因此父节点也需要改变,将分裂出的第二个节点的 K 1 K_1 K1 插入到父节点中,设分裂出的第二个节点的 K 1 K_1 K1 被插入到父节点第 x x x 个搜索码的位置,再将一个指向分裂出的第二个节点的指针插入到父节点的第 x + 1 x+1 x+1 个指针处,最后,将父节点中指向原节点的指针改为指向分裂出的第一个节点即可

在修改父节点的过程中,向父节点插入了一个指针,插入之后父节点的指针数可能超过最大值,这时候父节点也要分裂

非叶节点的分裂和叶节点的分裂略有不同,假设下图表示一个非叶节点,其包含的指针数已经超过了最大值

P 1 P_1 P1 K 1 K_1 K1 P 2 P_2 P2 K 2 K_2 K2 ⋯ \cdots P n − 1 P_{n-1} Pn1 K n − 1 K_{n-1} Kn1 P n P_n Pn

分裂出的第一个节点包含前一半指针

P 1 P_1 P1 K 1 K_1 K1 P 2 P_2 P2 K 2 K_2 K2 ⋯ \cdots P ⌊ n 2 ⌋ − 1 P_{ \lfloor \frac{n}{2} \rfloor -1} P2n1 K ⌊ n 2 ⌋ − 1 K_{ \lfloor \frac{n}{2} \rfloor -1} K2n1 P ⌊ n 2 ⌋ P_{ \lfloor \frac{n}{2} \rfloor } P2n

分裂出的第二个节点包含后一半指针

P ⌊ n 2 ⌋ + 1 P_{ \lfloor \frac{n}{2} \rfloor +1} P2n+1 K ⌊ n 2 ⌋ + 1 K_{ \lfloor \frac{n}{2} \rfloor +1} K2n+1 P ⌊ n 2 ⌋ + 2 P_{ \lfloor \frac{n}{2} \rfloor +2} P2n+2 K ⌊ n 2 ⌋ + 2 K_{ \lfloor \frac{n}{2} \rfloor +2} K2n+2 ⋯ \cdots P n − 1 P_{n-1} Pn1 K n − 1 K_{n-1} Kn1 P n P_n Pn

注意一个问题,分裂出的两个节点都不包含 K ⌊ n 2 ⌋ K_{ \lfloor \frac{n}{2} \rfloor } K2n ,事实上, K ⌊ n 2 ⌋ K_{ \lfloor \frac{n}{2} \rfloor } K2n 不在分裂出的任何一个节点中,而是被插入到了原节点的父节点中

这样,最初的插入可能会导致一系列的分裂,形成了一个自底向上递归的过程,一直递归到第一个不需要分裂的祖先,如果根节点也分裂了,则树高整体加一

删除

首先还是通过一次查询,找到待删除的搜索码所在的叶节点,然后在叶节点中删去搜索码和对应的指针

和插入相对,删除可能导致的问题是叶节点所包含的搜索码个数少于最小值

解决这一问题有两个方法,重新分配和合并

重新分配是找 DFS 序顺序相邻的叶节点借一个搜索码及其对应的指针,例如找 DFS 序顺序的上一个叶节点借,则将上一个叶节点包含的最后一个搜索码 K n K_n Kn 及其对应的指针移动到当前叶节点中,同时需要修改父节点中介于指向上一个叶节点的指针和指向当前叶节点的指针之间的搜索码,将其改为 K n K_n Kn

显然,在相邻叶节点没有多余搜索码可借的时候,无法重新分配,这时候要采用合并的方法

假设当前 B+ 树为 m m m 阶,相邻叶节点没有多余搜索码可借,意味着其中搜索码个数为 ⌊ m − 1 2 ⌋ \lfloor \frac{m-1}{2} \rfloor 2m1 ,而当前叶节点包含的搜索码个数少于 ⌊ m − 1 2 ⌋ \lfloor \frac{m-1}{2} \rfloor 2m1 ,因此,即便把两个叶节点合二为一,合并后的叶节点包含的搜索码个数也不会超过 m − 1 m-1 m1

合并后,父节点中需要删除一个搜索码和一个指针,删除后父节点的指针数可能会少于最小值,也需要重新分配或合并

非叶节点的重新分配和合并与叶节点略有不同,首先是重新分配,非叶节点的重新分配类似于一些平衡树的旋转,将父节点中介于指向上一个节点的指针和指向当前节点的指针之间的搜索码移动到当前节点中,而将上一个节点包含的最后一个搜索码移动到父节点中,最后还要把上一个节点包含的最后一个指针移动到当前节点中,插入到当前节点第一个指针的位置

非叶节点的合并相比于叶节点,会缺少一个搜索码,需要将父节点中介于指向上一个节点的指针和指向当前节点的指针之间的搜索码也加入合并,相当于在父节点上又删除了一个搜索码和一个指针

这样,最初的删除可能导致一系列合并,形成了一个自底向上的递归过程,一直递归到第一个不需要合并的祖先,如果根节点被删除到只剩下一个指针,则根节点的唯一子节点成为新的根节点,原根节点被删除

效率

由于 I/O 操作的速度远慢于 CPU ,因此性能的瓶颈在于 I/O 操作的次数

一般来说,B+ 树的一个节点大小不会超过一个磁盘块的大小,因此可以认为,每访问一个 B+ 树节点,最多进行一次 I/O 操作

对于一棵 m m m 阶的 B+ 树,若其中包含了 n 条记录,则单次操作的 I/O 操作数正比于 log ⁡ ⌊ m 2 ⌋ n \log_{\lfloor \frac{m}{2} \rfloor}{n} log2mn ,因此,一般在保证节点大小不超过一个磁盘块的大小的情况下,阶选的越大越好

优化

唯一化

搜索码不同于主码,不同行可能有相同的搜索码,搜索码不唯一会对 B+ 树索引的删除带来影响

例如在表中删除了一行,那么就需要在索引上删除这行对应的搜索码和指针,若这行的搜索码出现了很多次,由于不知道这些相同的搜索码中哪一个对应的指针是指向被删除的行,因此需要在 B+ 树索引中遍历所有这些相同的搜索码

为了避免不必要的遍历,可以在 B+ 树索引中保存复合搜索码,原本的搜索码作为复合搜索码的第一关键字,而复合搜索码的第二关键字是任意一个在所有行上唯一的属性,对于不同的行,这个复合搜索码总是唯一的,这样在索引上删除时,就可以通过复合搜索码直接定位

变长搜索码

之前的讨论中,我们一直认为搜索码是定长的,但实际上,搜索码可能是字符串之类的不定长的类型

当搜索码不定长时,我们不再用阶的概念来限制节点中搜索码和指针的个数,但是 B+ 树节点至少为半满的思想不变

对于变长搜索码,我们指定节点的最大容量,当一个节点保存的内容超过最大容量,则对其进行分裂,而当保存的内容少于最大容量的一半时,就进行重新分配或合并

空间优化

由于 B+ 树的节点至少是半满,因此在最坏情况下,B+ 树会浪费一半的空间,稍微修改一下插入和删除的方法,可以优化空间利用率,这个优化方法对叶节点和非叶节点都适用

向一个已满的节点插入时,先不考虑分裂,而是先考虑在已满的节点和其相邻节点之间进行重新分配,只有在相邻节点也满了的时候才进行分裂,在已满的节点、相邻节点、新分裂的节点三者之间平均分配搜索码和指针,每个节点都是三分之二满的

当一个节点删除后不足三分之二满,就进行重新分配,找相邻的超过三分之二满的节点借一个搜索码和指针,当相邻的两个节点均为三分之二满时,无法重新分配,这时将这三个节点合并为两个

经过这种方法优化的 B+ 树也被称作 B* 树,如果在插入和删除的时候,考虑更多的相邻节点,则可以进一步优化空间利用率

建树

给一个表创建索引时,需要一次性地把所有记录插入一棵空的 B+ 树

朴素的方法是,将待插入的记录依次插入,一次插入需要数次 I/O 操作,当待插入的记录特别多时,朴素的方法效率较低

考虑将所有记录按搜索码排序,之后自底向上构建 B+ 树,假设 B+ 树是 m m m 阶的,将排好序的记录每 m − 1 m-1 m1 个分为一组,每一组中的记录存放在一个叶节点中,构建好 B+ 树的一层后,通过该层所有节点的地址以及其包含的最小搜索码,可以递归地构建出上一层的所有节点

这种方法需要一次外部排序,通常情况下,一次外部排序所需的 I/O 操作数是一次顺序遍历所需的 I/O 操作数的几倍,此外,自底向上构建 B+ 树所需的 I/O 操作数等于 B+ 树的节点数,通常情况下比 B+ 树包含的记录数少很多,在待插入的记录非常多时,这种方法的效率高于朴素方法

对于一个已经建好的 B+ 树索引的表,有时会向其中添加非常多行,这时候可以考虑先删除其上的 B+ 树索引,然后在所有行被添加完毕之后再重新构建

B 树索引

B 树也是一种多叉平衡树,实际上从名字就可以看出,B+ 树是 B 树的改进

B 树和 B+ 树主要的区别在于,B+ 树中每个搜索码在叶节点中恰好出现一次,并且在非叶节点中也可能会出现,而 B 树中每个搜索码在叶节点和非叶节点中总共只出现一次

由于每个搜索码在 B 树中只出现一次,因此 B 树的非叶节点和 B+ 树的有所不同,其结构如下:

P 1 P_1 P1 B 1 B_1 B1 K 1 K_1 K1 P 2 P_2 P2 B 2 B_2 B2 K 2 K_2 K2 ⋯ \cdots P n − 1 P_{n-1} Pn1 B n − 1 B_{n-1} Bn1 K n − 1 K_{n-1} Kn1 P n P_n Pn

其中 P 1 , P 2 , ⋯   , P n P_1,P_2, \cdots ,P_n P1,P2,,Pn K 1 , K 2 , ⋯   , K n K_1,K_2, \cdots ,K_n K1,K2,,Kn 的意义和 B+ 树一样,而 B 1 , B 2 , ⋯   , B n − 1 B_1,B_2, \cdots ,B_{n-1} B1,B2,,Bn1 n − 1 n-1 n1 个指针,分别指向 K 1 , K 2 , ⋯   , K n − 1 K_1,K_2, \cdots ,K_{n-1} K1,K2,,Kn1 所对应的行的物理地址

由于 B+ 树中的搜索码可能会出现不止一次,而 B 树中的搜索码只出现一次,因此 B 树中的搜索码出现的次数要少于 B+ 树,但在非叶节点上,B 树需要额外保存 B 1 , B 2 , ⋯   , B n − 1 B_1,B_2, \cdots , B_{n-1} B1,B2,,Bn1 ,因此若给每个非叶节点分配相同大小的空间,B 树的非叶节点扇出要小于 B+ 树

可以看出 B 树和 B+ 树各有优劣,通常我们认为,B+ 树的效率更加稳定,并且实现的复杂性上要小于 B 树,因此我们才说,B+ 树是 B 树的改进,目前许多数据库系统采用 B+ 树索引而不是 B 树索引,还有些数据库系统将 B+ 树称作 B 树


散列索引


静态散列

散列表天然支持插入、删除和查询,而且平均时间效率相当高,通常情况下要高于 B+ 树,因此可以利用散列表实现索引

首先创建一定数量的桶,然后通过散列函数将每个搜索码及指向对应行物理地址的指针映射到桶上,就成功构建出了散列索引,典型情况下,一个桶的大小等于一个磁盘块的大小

溢出是一个需要解决的问题,当有太多的搜索码被映射到同一个桶时,这个桶有可能会存不下所有的搜索码和指针,这个时候就会发生溢出

解决溢出的方法有不少,数据库系统一般采用附加溢出桶的方式,当某一个桶存不下所有的搜索码和指针时,就再开一个溢出桶,附加在原桶的后面

溢出并不是静态散列索引需要面对的最大问题,静态散列索引的最大问题在于,静态散列的散列函数一旦确定就很难更改,散列函数的固定意味着桶的个数固定(不算溢出桶),因此在设计散列函数时,必须考虑桶的个数

在给一个表创建索引时,我们一般很难知道这个表最终会有多少行,因此我们无论创建多少个桶,都存在不合适的可能,如果桶的个数太少,有可能会因为表的规模太大而产生过多的溢出桶,溢出桶越多,意味着散列表一次操作所需的 I/O 操作次数越多,而如果桶的个数太多,有可能因为表的规模太小而浪费许多空间

有一种解决方案是,创建索引时根据当前表的大小来确定桶的个数,从而确定散列函数,而当表的规模明显增大或减小时,进行重组,通过修改散列函数的方式改变桶的个数,这种方法的缺点在于,重组时必须将散列中所有记录用新的散列函数重新映射一遍,十分耗时

动态散列

动态散列与静态散列的最大区别在于,动态散列可以动态地增加或减少桶的个数,从而适应表的规模,下面介绍一种称为可扩充散列(extendable hashing)的动态散列

散列前缀

在为动态散列设计散列函数时,我们令散列函数可以把搜索码均匀映射到所有的 b b b 位二进制数,也就是说映射到 0 至 2 b − 1 2^b-1 2b1 的所有整数,一个典型的 b b b 值是 32

显然我们不可能创建 2 b 2^b 2b 个桶,实际上,我们创建的桶远少于 2 b 2^b 2b 个,我们创建的每一个桶都有一个二进制散列前缀,一个桶保存所有满足以下条件的搜索码及其对应的指针:搜索码的散列值二进制的一个前缀恰好是该桶的散列前缀

每个桶的散列前缀位数不一,但是不可能出现一个桶的散列前缀是另一个桶的散列前缀的前缀,这样就可以杜绝一个搜索码被映射到多个桶中,实际实现中,我们不需要为每个桶维护它的散列前缀值,只需要维护散列前缀的位数

桶地址表

和静态散列不同,由于现在一个桶对应多个散列值,因此对于一个搜索码,即使我们计算出散列值,也无法直接将其映射到对应的桶中,我们利用桶地址表来解决这个问题,计算出散列值后,再通过查询桶地址表,就可以将搜索码映射到对应的桶中

首先我们需要维护所有桶的散列前缀位数的最大值 c c c ,桶地址表是一个指针数组,长度为 2 c 2^c 2c ,第 i i i 个指针指向散列值的前缀为 i i i 的搜索码对应的桶,对于某些散列前缀位数小于 c c c 的桶,桶地址表中会有多个指针指向它

查询

相比于静态散列的查询,动态散列只是多出了一个桶地址表作为中间层,计算出待查询搜索码的散列值后,再通过查询桶地址表,就可以找到搜索码对应的桶

插入

首先还是查询一次,找到搜索码对应的桶,再直接向桶中插入搜索码及其对应的指针

现在问题在于,插入之后的桶可能会溢出,和静态散列不同,动态散列解决溢出的方法不是附加溢出桶,而是进行桶的分裂

将原来的桶分裂为两个桶之后,通过在原散列前缀后附加 0 或 1 ,可以得到两个新桶的散列前缀,之后原桶中保存的所有搜索码及其对应的指针,按照搜索码的散列值的前缀,被重新映射到两个新桶中,这之后,只有及其极端的情况,才会需要再次分裂

分裂完成之后,需要更新桶地址表,设新桶的散列前缀位数的最大值为 d d d ,如果 d d d 没有超过 c c c ,则只需要在桶地址表中简单地更改一下指向旧桶的指针,让它们指向新桶,而如果超过 c c c ,则需要先重构桶地址表,将其长度增加至 2 d 2^d 2d ,这时,原表中的一项被新表中的多项代替,新表中的这些项包含和原表中的项一样的指针,最后再更改指向旧桶的指针,让它们指向新桶

删除

和插入一样,也是查询一次,找到搜索码对应的桶,再直接在桶中进行删除

和插入对应,删除之后,某些桶可能可以合并,桶地址表的规模也可能可以减半,但需要注意的是,只有当桶很空时才能合并,而桶地址表规模减半更需要谨慎,因为如果不谨慎,可能将刚将某些桶合并,下一次插入又导致合并后的桶分裂,或刚给桶地址表规模减半,下一次插入又导致桶地址表规模复原

效率

假设对桶和桶地址表的访问各需要一次 I/O 操作,则查询和大多数情况下的插入删除只需要两次 I/O 操作,虽然桶的合并与分裂、更改桶地址表的规模可能会需要更多的 I/O 操作次数,但这些操作很少,均摊下来也几乎不影响时间效率

而动态散列的空间效率比较高,因为它能根据保存的记录数动态地调整占用空间

相比于静态散列,动态散列不会因为记录数的增加而导致时间效率下降,并且空间效率一直都保持较高,因此通常情况下我们认为动态散列优于静态散列,只要能忍受动态散列相比于静态散列的复杂性

散列索引和 B+ 树索引的比较

平均的时间效率上,散列索引占优,但散列索引的时间效率不如 B+ 树索引稳定,最坏的时间效率不如 B+ 树索引

空间效率上 B+ 树索引占优

此外,在选择索引类型时,还需要考虑查询的类型,散列索引是无法加速搜索码上的范围查询,例如:

select *
from r
where A ≤ \le b

如果预计到未来会频繁地出现范围查询,就应当选择 B+ 树索引,否则再根据其它因素选择 B+ 树索引和散列索引

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值