InnoDB 级别的缓存Buffer Pool详解

MySQL级别的缓存

image.png

说起缓存,我们回忆起MySQL的Server层有一个缓存,这个缓存在MySQL8.0之后被直接禁用了。

随着技术的进步,经过时间的考验,MySQL 的工程团队发现启用缓存的好处并不多。

首先,查询缓存的效果取决于缓存的命中率,只有命中缓存的查询效果才能有改善,因此无法预测其性能。而且为了维护缓存结果的正确性,我们还需要频繁的去更新缓存。

其次,查询缓存的另一个大问题是它受到单个互斥锁的保护。在具有多个内核的服务器上,大量查询会导致大量的互斥锁争用。 (所以用了InnoDB级别的行锁)

通过基准测试发现,大多数工作负载最好禁用查询缓存(5.6 的默认设置): 按照官方所说的:造成的问题比它解决问题要多的多, 弊大于利就直接砍掉了。

那么,是否说MySQL中就不存在缓存,彻底就是一个持久化型数据库了呢?

并不是,缓存机制我们放到了存储引擎汇中来实现。本章就来详细介绍一下InnoDB级别的缓存。

image.png

InnoDB级别缓存的重要性

我们知道,对于使用 InnoDB 作为存储引擎的表来说,不管是用于存储用户数据的索引(包括聚簇索引和二级索引),还是各种系统数据,都是以页的形式存放在表空间中的,也就是说我们的数据说到底还是存储在磁盘上的。

但是磁盘的速度慢,所以 InnoDB 存储引擎在处理客户端的请求时,当需要访问某个页的数据时,就会把完整的页的数据全部加载到内存中,也就是说即使我们只需要访问一个页的一条记录,那也需要先把整个页的数据加载到内存中。

将整个页加载到内存中后就可以进行读写访问了,在进行完读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存起来,这样将来有请求再次访问该页面时,就可以省去磁盘 IO 的开销了。换句话说,图中再次执行一个查询,就不需要InnoDB再去访问文件系统了。

Buffer Pool缓冲池

查看Buffer Pool的大小

InnoDB 为了缓存磁盘中的页,在 MySQL 服务器启动的时候就向操作系统申 请了一片连续的内存,他们给这片内存起了个名,叫做 Buffer Pool(中文名是缓 冲池)。那它有多大呢?这个其实看我们机器的配置,默认情况下 Buffer Pool 只有 128M 大小,这个值其实是偏小的。

show variables like 'innodb_buffer_pool_size';

image.png

修改Buffer Pool的大小

可以在启动服务器的时候配置 innodb_buffer_pool_size 参数的值,它表示 Buffer Pool 的大小,就像这样:

[server]

innodb_buffer_pool_size = 268435456

其中,268435456 的单位是字节,也就是指定 Buffer Pool 的大小为 256M。 需要注意的是,Buffer Pool 也不能太小,最小值为5M(当小于该值时会自动设置成 5M)。

控制块与缓存页

Buffer Pool 中默认的缓存页大小和在磁盘上默认的页大小是一样的,都是 16KB。为了更好的管理这些在 Buffer Pool 中的缓存页,InnoDB 为每一个缓存页 都创建了一些所谓的控制信息,这些控制信息包括该页所属的表空间编号、页号、 缓存页在 Buffer Pool 中的地址、链表节点信息、一些锁信息以及 LSN 信息,当然还有一些别的控制信息。

每个缓存页对应的控制信息占用的内存大小是相同的,我们称为控制块。控制块和缓存页是一一对应的,它们都被存放到 Buffer Pool 中,其中控制块被存 放到 Buffer Pool 的前边,缓存页被存放到 Buffer Pool 后边,所以整个 Buffer Pool 对应的内存空间看起来就是这样的:

image.png

每个控制块大约占用缓存页大小的 5%,而我们设置的 innodb_buffer_pool_size 并不包含这部分控制块占用的内存空间大小,也就是说 InnoDB 在为 Buffer Pool 向操作系统申请连续的内存空间时,这片连续的内存空间一般会比 innodb_buffer_pool_size 的值大 5%左右

对页控制的意义

free (空闲)链表的管理

最初启动 MySQL 服务器的时候,需要完成对 Buffer Pool 的初始化过程,就 是先向操作系统申请 Buffer Pool 的内存空间,然后把它划分成若干对控制块和缓 存页。但是此时并没有真实的磁盘页被缓存到 Buffer Pool 中(因为还没有用到), 之后随着程序的运行,会不断的有磁盘上的页被缓存到 Buffer Pool 中。

那么问题来了,从磁盘上读取一个页到 Buffer Pool 中的时候该放到哪个缓存页的位置呢?或者说怎么区分 Buffer Pool 中哪些缓存页是空闲的,哪些已经被使用了呢?

刚刚完成初始化的 Buffer Pool 中所有的缓存页都是空闲的,所以每一个缓存页对应的控制块都会被加入到 free 链表中,假设该 Buffer Pool 中可容纳的缓存页数量为 n,那增加了 free 链表的效果图就是这样的:

image.png

有了这个 free 链表之后,每当需要从磁盘中加载一个页到 Buffer Pool 中时, 就从 free 链表中取一个空闲的缓存页,并且把该缓存页对应的控制块的信息填 上(就是该页所在的表空间、页号之类的信息),然后把该缓存页对应的 free 链表节点从链表中移除,表示该缓存页已经被使用了。

一个查询如何快速命中缓存页

我们前边说过,当我们需要访问某个页中的数据时,就会把该页从磁盘加载 到 Buffer Pool 中,如果该页已经在 Buffer Pool 中的话直接使用就可以了。那么判断查询页是否存在于Buffer Pool中,需要遍历 Buffer Pool 中各个缓存页么?

InnoDB使用表空间号 + 页号作为 key,缓存页作为 value 创建一个哈希表,在需要访问某个页的数据时,先从哈希表中根据表空间号 + 页号看看有没有对应的缓存页,如果有,直接使用该缓存页就好,如果没有,那就从 free 链表中选一个空闲的缓存页,然后把磁盘中对应的页加载到该缓存页的位置。

flush (脏页)链表的管理

如果我们修改了 Buffer Pool 中某个缓存页的数据,那它就和磁盘上的页不一致了,这样的缓存页也被称为脏页(英文名:dirty page)。

当然,最简单的做法就是每发生一次修改就立即同步到磁盘上对应的页上,但是频繁的往磁盘中写数据会严重的影响程序的性能。所以每次修改缓存页后,我们并不着急立即把修改 同步到磁盘上,而是在未来的某个时间点进行同步。

但是如果不立即同步到磁盘的话,那之后再同步的时候我们怎么知道 Buffer Pool 中哪些页是脏页,哪些页从来没被修改过呢?

所以,需要再创建一个存储脏页的链表,凡是修改过的缓存页对应的控制块都会作为一个节点加入到一个链表中,因为这个链表节点对应的缓存页都是需要被刷新到磁盘上的,所以也叫 flush 链表。链表的构造和 free 链表差不多。

LRU(热点数据) 链表的管理

缓存不够的窘境

Buffer Pool 对应的内存大小毕竟是有限的,如果需要缓存的页占用的内存大小超过了 Buffer Pool 大小,也就是 free 链表中已经没有多余的空闲缓存页的时候该咋办?当然是把某些旧的缓存页从 Buffer Pool 中移除,然后再把新的页放进来,那么问题来了,移除哪些缓存页呢?

为了回答这个问题,我们还需要回到我们设立 Buffer Pool 的初衷,我们就是想减少和磁盘的 IO 交互,最好每次在访问某个页的时候它都已经被缓存到 Buffer Pool 中了。也就是我们的缓存率越高越好。

那么怎样算热点呢?回想一下我们的微信聊天列表,排在前边的都是最近新联系过的,排在越后面的,就代表越久没联系的。假如列表能容纳下的联系人有限,那么肯定先删除那些列表尾部救救不联系的人了。基于这个思想,InnoDB创建了LRU链表。

简单的LRU链表

这个链表是为了按照最近最少使用的原则去淘汰缓存页的,所以这个链表可以被称为 LRU 链表(LRU 的英文全称:Least Recently Used)。 当我们需要访问某个页时,可以这样处理 LRU 链表:

  1. 如果该页不在 Buffer Pool 中

把该页从磁盘加载到 Buffer Pool 中的缓存页时,就把该缓存页对应的控制块作为节点塞到 LRU 链表的头部

  1. 如果该页已经缓存在 Buffer Pool 中

再次把该页对应的控制块移动到 LRU 链表的头部。

也就是说:只要我们使用到某个缓存页,就把该缓存页调整到 LRU 链表的头部,这样 LRU 链表尾部就是最近最少使用的缓存页。所以当 Buffer Pool 中的空闲缓存页使用完时,到 LRU 链表的尾部找些缓存页淘汰就行了。

MySQL中基于简答 LRU 链表的变种

传统的LRU缓冲池算法十分直观,OS,memcache等很多软件都在用,MySQL为啥这么矫情,不能直接用呢?那是因为,结合MySQL的一些优化特性,导致了以下两个问题:

  • 预读失效
  • 缓冲池污染

预读失败

我们知道,MySQL为了节约磁盘IO的成本,有这么一个优化:当加载一个磁盘页的时候,干脆把隔壁几个磁盘页一起加载进内存算了。这个操作就叫预读。然而MySQL这个优化完全是凭运气进行的一个预判,在很多情况下预读进来的内存从头到尾都没有使用过。

对于MySQL而言,内存是多么金贵的部分。这些仅凭预判就加载进内存的磁盘页无疑会浪费大量的内存空间。

那干脆关闭预读功能算了?肯定不行,这个预判确实可以大幅度减少在磁盘IO上花费的时间。那么InnoDB选择针对这个问题优化LRU链表。

缓冲池污染

当我们进行因为各种原因,对表进行全表扫描时。假设真正能用到的磁盘页仅仅占很小的一部分。那么这个表就会有一大堆没用的页加载进内存。

针对这两种情况,LRU链表进行了优化。目标肯定是让这种无意义磁盘页尽量别占用珍贵的内存资源。

优化一:新增新生代,老年代解决预读失败

InnoDB 把这个 LRU 链表按照一定比例分成两截,分别是:

  • 存储使用频率非常高的缓存页,所以这一部分链表也叫做热数据,或者称 young 区域。
  • 存储使用频率不是很高的缓存页,所以这一部分链表也叫做冷数据, 或者称 old 区域。

image.png    默认情况下,old 区域在 LRU 链表中所占的比例是 37%,也 就是说 old 区域大约占 LRU 链表的 3/8。这个比例我们是可以设置的。

InnoDB 规定,当磁盘上的某个页面在初次加载到 Buffer Pool 中的某个缓存页时,该缓存页对应的控制块会被放到 old 区域的头部。这样针对预读到 Buffer Pool 却不进行后续访问的页面就会被更早的从 old 区域逐出。若加入到老年代头节点的数据页被真正的读取,那么就会进入新生代中,相比于老年代拥有了更长的生命周期。

我们来一个更加详细的流程:

假如是预读页

image

假如有一个页号为50的新页被预读加入缓冲池:

  1. 50只会从老生代头部插入,老生代尾部(也是整体尾部)的页会被淘汰掉;
  2. 假设50这一页不会被真正读取,即预读失败,它将比新生代的数据更早淘汰出缓冲池;

假如是被读取页

image

假如50这一页立刻被读取到,例如SQL访问了页内的行row数据:

  1. 它会被立刻加入到新生代的头部;
  2. 新生代的页会被挤到老生代,此时并不会有页面被真正淘汰;

优化二:新增老生代停留时间窗口,解决污染问题

全表扫描和磁盘页的区别在哪里呢?预读的磁盘页可能从头到尾都不会被命中。而全表扫描则是扎扎实实的命中了该表空间下的每一个磁盘页,这样每个数据页(包含大量无意义数据页)会由老年代进入新生代。这样的分代就完全没意义了。

因此,MySQL缓冲池加入了一个“老生代停留时间窗口”的机制:

  1. 假设T=老生代停留时间窗口;
  2. 插入老生代头部的页,即使立刻被访问,并不会立刻放入新生代头部;
  3. 只有满足“被访问”并且“在老生代停留时间”大于T,才会被放入新生代头部;

加入“老生代停留时间窗口”策略后,短时间内被大量加载的页,并不会立刻插入新生代头部。当加入老年代后的T时间段后,如果依旧被查询缓存命中,此时则由老年代进入新生代。

这样进行优化,会优先淘汰那些,短期内仅仅访问了一次的页。用通俗点的话来讲,就是真正的热数据页,必然是经历的起时间考验的。

Buffer Pool的刷盘时机

后台有专门的线程每隔一段时间负责把脏页刷新到磁盘,这样可以不影响用 户线程处理正常的请求。(这就是个垃圾回收器啊)。

那么我们内存的刷盘操作,正式基于flush(脏页)链表以及LRU(热点)链表而言的。

主力flush链表刷盘

这个磁盘全是脏页,不刷你刷谁?

后台线程也会定时从 flush 链表中刷新一部分页面到磁盘,刷新的速率取决于当时系统是不是很繁忙。这种刷新页面的方式被称之为 BUF_FLUSH_LIST。

flush链表的尾部都是一些存在最久远的脏页。因此刷他们是有道理的。尽管这些数据可能是热数据,但是太久没刷也会长期占用大量的redo log内容,所以还是得刷一下重新来。

冷数据LRU链表刷盘

我们说,LRU的目的是在Buffer Pool满了之后再淘汰冷数据。缓存的数据理论情况下越多越好(提高查询命中率)。即时冷了的数据再次被访问的概率也是很大的。因此一般不主动清理LRU链表的数据。

但是后台线程会定时从 LRU 链表尾部开始扫描一些页面,如果从里边儿发现脏页,会把它们刷新到磁盘。各位想想,这个缓存数据页太久不被命中导致凉了也就算了,同时你还是个脏页,那真的是没什么理由留下它了。

这种刷新页面的方式被称之为 BUF_FLUSH_LRU。

其他一些不得已的刷盘方式

有时候后台线程刷新脏页的进度比较慢,导致用户线程在准备加载一个磁盘页到 Buffer Pool 时没有可用的缓存页,这时就会尝试看看 LRU 链表尾部有没有可以直接释放掉的未修改页面,如果没有的话会不得不将 LRU 链表尾部的一个脏页同步刷新到磁盘(和磁盘交互是很慢的,这会降低处理用户请求的速度)。这种刷新单个页面到磁盘中的刷新方式被称之为 BUF_FLUSH_SINGLE_PAGE。 (这才是LRU链表存在的目的)、

当然,有时候系统特别繁忙时,也可能出现用户线程批量的从 flush 链表中刷新脏页的情况,很显然在处理用户请求过程中去刷新脏页是一种严重降低处理速度的行为,这属于一种迫不得已的情况。

多个 Buffer Pool 实例提高并发速度

我们上边说过,Buffer Pool 本质是 InnoDB 向操作系统申请的一块连续的内 存空间,在多线程环境下,访问 Buffer Pool 中的各种链表都需要加锁处理,在 Buffer Pool 特别大而且多线程并发访问特别高的情况下,单一的 Buffer Pool 可能会影响请求的处理速度。

所以在 Buffer Pool 特别大的时候,我们可以把它们拆分 成若干个小的 Buffer Pool,每个 Buffer Pool 都称为一个实例,它们都是独立的, 独立的去申请内存空间,独立的管理各种链表,所以在多线程并发访问时并不会相互影响,从而提高并发处理能力。

Buffer Pool以chunk为单位改变大小

在 MySQL 5.7.5 之前,Buffer Pool 的大小只能在服务器启动时通过配置 innodb_buffer_pool_size 启动参数来调整大小,在服务器运行过程中是不允许调整该值的。

不过 MySQL 在 5.7.5 以及之后的版本中支持了在服务器运行过程中调 整 Buffer Pool 大小的功能, 但是有一个问题,就是每次当我们要重新调整 Buffer Pool 大小时,都需要重 新向操作系统申请一块连续的内存空间,然后将旧的 Buffer Pool 中的内容复制到这一块新空间,这是极其耗时的。

所以 MySQL 决定不再一次性为某个 Buffer Pool 实例向操作系统申请一大片连续的内存空间,而是以一个所谓的 chunk 为单位向 操作系统申请空间。也就是说一个Buffer Pool实例其实是由若干个chunk组成的, 一个 chunk 就代表一片连续的内存空间,里边儿包含了若干缓存页与其对应的控制块:

image.png

正是因为发明了这个 chunk 的概念,我们在服务器运行期间调整 Buffer Pool 的大小时就是以 chunk 为单位增加或者删除内存空间,而不需要重新向操作系统 申请一片大的内存,然后进行缓存页的复制。

InnoDB四大特性——change buffer(写缓冲)

曾经几何,总是吧flush和change buffer的概念一直混淆。感觉他们都是统计脏数据的,但是傻傻的总是分不清他们的区别。我们再一起理一理。

什么是change buffer?

在MySQL5.5之前,叫插入缓冲(insert buffer),只针对insert做了优化;现在对delete和update也有效,叫做写缓冲(change buffer)。

它是一种应用在非唯一普通索引页(non-unique secondary index page)不在缓冲池中,对页进行了写操作,并不会立刻将磁盘页加载到缓冲池,而仅仅记录缓冲变更(buffer changes),等未来数据被读取时,再将数据合并(merge)恢复到缓冲池中的技术。写缓冲的目的是降低写操作的磁盘IO,提升数据库性能。

flush 链表的添加元素的条件

我们说 free 链表的添加条件是什么?

  1. 这个页已经从磁盘读入了Buffer Pool中。
  2. 当我们修改了此页数据,此缓存页变为了脏页,加入到flush链表中等待刷盘。

change buffer的作用

不清楚change buffer作用的朋友,做了下面的对比应该一清二楚了。

没有change buffer时,更新一条内存中不存在的页

那么假设我们现在读取的元素不在内存中,此时有人写了一个update语句更新数据页,InnoDB引擎的工作流程如下:

  1. 从磁盘加载数据页到缓冲池,一次磁盘随机读操作;
  2. 修改缓冲池中的页,一次内存操作;
  3. 写入redo log,一次磁盘顺序写操作;

没有命中缓冲池的时候,至少产生一次磁盘IO,对于写多读少的业务场景,是否还有优化的空间呢?

当出现change buffer时,更新一条内存中不存在的页(和flush链表的区别)

  1. 在写缓冲中记录这个操作,一次内存操作;
  2. 写入redo log,一次磁盘顺序写操作;

可以发现,这样change buffer的出现直接减少了一次磁盘IO。

读取数据是否会出现一致性问题?

当然不会,我们change buffer中,相当于以页为单位,存储了许多数据修改的逻辑。当change buffer没有刷到磁盘时,磁盘中的数据肯定是脏数据。那么读出来的数据肯定是不对的。

解决方案也很简单,就是先把脏数据读到内存中,再根据change buffer中对此数据页修改记录,还原出最新版本的数据页信息即可。(注意,这时候change buffer相关此页的数据就没了,同步到缓存中了。之后再修改此磁盘页的数据,就会进入flush链表中了)。是不是感觉融会贯通多了?

change buffer的刷盘时机

  1. 如上面描述的,当change buffer中有数据的时,发生读盘操作。会进行一次磁盘读取,再配合change buffer获取到最新数据。此时change buffer中的该页信息会刷掉;
  2. 有一个后台线程,会判断数据库空闲时刷盘;
  3. 数据库缓冲池不够用时;
  4. 数据库正常关闭时;
  5. redo log写满时;(redo log几乎不会写满,否则会造成MySQL吞吐量在一段时间内严重下降)

change buffer中存在数据时发生宕机怎么办?

每次change buffer中的数据会同步到redo log中,数据库异常崩溃,能够从redo log中恢复数据。

为什么change buffer是只针对于二级索引的优化呢?

我们来对比主键索引与二级索引进行一个新增操作的区别:

即将插入的记录所在目标页在内存中

  1. 对于唯一索引来说,找到3和5之间的位置,判断到没有冲突,插入这个值,语句执行结束;
  2. 对于普通索引来说,找到3和5之间的位置,插入这个值,语句执行结束。

这样看来,普通索引和唯一索引对更新语句性能影响的差别,只是一个判断,只会耗费微小的CPU时间。

但,这不是我们关注的重点。

即将插入的记录所在目标页不在内存中

  1. 对于唯一索引来说,需要将数据页读入内存,判断到没有冲突,插入这个值,语句执行结束;
  2. 对于普通索引来说,则是将更新记录在change buffer,语句执行就结束了。

将数据从磁盘读入内存涉及随机IO的访问,是数据库里面成本最高的操作之一。change buffer因为减少了随机磁盘访问,所以对更新性能的提升是会很明显的。(我们可能积累了一个页中的很多数据,然后一起更新整个页,从而减少IO)。

在面试过程中,可以提起解决过这么一个问题。某天发现数据库的内存命中率从99%降低到了75%,整个系统处于阻塞状态,更新语句全部堵住。而探究其原因后,我发现这个业务有大量插入数据的操作,而他在前一天把其中的某个普通索引改成了唯一索引。

change buffer和redo的对比

这俩有啥可比性啊?一个缓存数据,一个日志文件,八竿子打不着啊!

然而,有了解过redo log的朋友,会知道redo log有一个特性和change buffer共有的一个特性是:尽量减少随机读写。那么围绕着这个角度我们来分析一下change buffer与redo log的区别。

redo log少写磁盘

现在,我们要在表上执行这个插入语句:

insert into t(id,k) values(id1,k1),(id2,k2);

这里,我们假设当前是以k为索引的二级B+树索引,查找到位置后,k1所在的数据页在内存(InnoDB buffer pool)中,k2所在的数据页不在内存中。如图所示是带change buffer的更新状态图。

image.png

分析这条更新语句,你会发现它涉及了四个部分:内存、redo log(ib_log_fileX)、 数据表空间(t.ibd)、系统表空间(ibdata1)。

这条更新语句做了如下的操作(按照图中的数字顺序):

  1. Page 1在内存中,直接更新内存;
  2. Page 2没有在内存中,就在内存的change buffer区域,记录下“我要往Page 2插入一行”这个信息
  3. 将上述两个动作记入redo log中(图中3和4)。

做完上面这些,事务就可以完成了。所以,你会看到,执行这条更新语句的成本很低,就是写了两处内存,然后写了一处磁盘(两次操作合在一起写了一次磁盘),而且还是顺序写的。(注意:上述的三个步骤是一个事务,也就是必须redo log写完,事务才算搞定,这也印证了为啥redo log一定可以恢复change buffer中的数据)

change buffer少读磁盘

我们现在要执行

select * from t where k in (k1, k2)

这里,我画了这两个读请求的流程图。

如果读语句发生在更新语句后不久,内存中的数据都还在,那么此时的这两个读操作就与系统表空间(ibdata1)和 redo log(ib_log_fileX)无关了。所以,我在图中就没画出这两部分。

image

从图中可以看到:

  1. 读Page 1的时候,直接从内存返回。有几位同学在前面文章的评论中问到,WAL之后如果读数据,是不是一定要读盘,是不是一定要从redo log里面把数据更新以后才可以返回?其实是不用的。你可以看一下图3的这个状态,虽然磁盘上还是之前的数据,但是这里直接从内存返回结果,结果是正确的。
  2. 要读Page 2的时候,需要把Page 2从磁盘读入内存中,然后应用change buffer里面的操作日志,生成一个正确的版本并返回结果。

可以看到,直到需要读Page 2的时候,这个数据页才会被读入内存。到真正写盘时是在数据库空闲或者不得已的时候才会进行。而当刷盘的时候,可能会有多条语句多次操作磁盘,此时将整个页整体刷入磁盘,就减少了许多次与磁盘之间的交互,从而达到减少磁盘IO的目的。

总结

所以,如果要简单地对比这两个机制在提升更新性能上的收益的话,redo log 主要节省的是随机写磁盘的IO消耗(转成顺序写),而change buffer主要节省的则是随机读磁盘的IO消耗。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

大将黄猿

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值