微信iOS SQLite源码优化实践

限时加码!20+主流AI编程工具免费用 购周边加赠Coding Plan Lite,Claude Code、Cursor等即刻畅享,学习进阶更高效! 阅读详情

# 微信iOS SQLite源码优化实践


Dev Club 是一个交流移动开发技术,结交朋友,扩展人脉的社群,成员都是经过审核的移动开发工程师。每周都会举行嘉宾分享,话题讨论等活动。

本期,我们邀请了腾讯WXG iOS 开发工程师——张三华,为大家分享《微信iOS SQLite源码优化实践》。

### 分享内容简介:

SQLite是微信iOS选用的数据库,随着微信iOS客户端业务的增长,在重度用户的场景下,性能瓶颈逐渐显现。靠单纯地修改SQLite的参数配置,已经不能彻底解决问题,因此我们尝试从源码开始做深入的优化。

### 内容大体框架:

  1. SQLite对于多线程的处理和不足及微信的优化

  2. SQLite在I/O上可压榨的性能

  3. 其他细节优化

### 下面是本期分享内容整理

Hello,大家好,我是张三华,目前在微信主要负责 iOS 的基础优化工作。第一次进行这种微信群分享,可能准备的不是太充分。若有任何疑问,欢迎在分享结束后提问。

下面开始我们今天的分享。

## 引言

SQLite 是我们在移动端常用的数据库,微信也是基于它封装了一层 ObjC 接口。我们知道,微信里消息的收发是很频繁的,尤其是对于重度用户,这对于数据库的多线程并发和 I/O 是很大的挑战。

通常对这部分做优化,有两种方式:

* 一是修改 SQLite 的参数,如 Cache Size 等

* 二是改业务层调用,如主线程操作 dispatch 到子线程。

然而,前者有明显的瓶颈,后者则是个 endless 的工作。我们希望能一劳永逸地解决同类问题。这就是我们本次所要分享的优化。

## 1. 多线程并发优化

### 1.1 SQLite 多句柄方案

我们先讲 SQLite 所提供的多线程并发方案。它对这方面的支持做的很不错,在使用上,只需

  1. 开启句柄多线程支持的配置 PRAGMA SQLITE_THREADSAFE=2

  2. 确保同一个句柄同一时间只有一个线程在操作

  3. (可选)开启 WAL 模式 PRAGMA journal_mode=WAL

此时写操作会先 append 到 wal 文件末尾,而不是直接覆盖旧数据。而读操作开始时,会记下当前的 WAL 文件状态,并且只访问在此之前的数据。这就确保了多线程读与读、读与写之间可以并发地进行。

### 1.2 Busy Retry 方案

而写与写之间仍会互相阻塞。SQLite 提供了 Busy Retry 的方案,即发生阻塞时,会触发 Busy Handler,此时可以让线程休眠一段时间后,重新尝试操作。重试一定次数依然失败后,则返回 SQLITE_BUSY 错误码。

下面这段代码是 SQLite 默认的 Busy Handler

这里写图片描述

### 1.3 Busy Retry 方案的不足

上面介绍了 SQLite 多线程并发方案,接下来我们把焦点放在 Busy Retry 这个方案的不足上。

Busy Retry 的方案虽然基本能解决问题,但对性能的压榨做的不够极致。在 Retry 过程中,休眠时间的长短和重试次数,是决定性能和操作成功率的关键。

然而,它们的最优值,因不同操作不同场景而不同。若休眠时间太短或重试次数太多,会空耗 CPU 的资源;若休眠时间过长,会造成等待的时间太长;若重试次数太少,则会降低操作的成功率。如下图
这里写图片描述

可以看到

* CPU空转那段,线程一操作还没结束,这里空耗了 CPU 的资源

* 线程闲置那段,线程一已经结束,而线程二仍在等待,空耗了时间

对于这个的优化,简单的方法可以是修改休眠时间,尽最大限度缩短以上两段空耗的资源。

我们通过 A/B Test 对不同休眠时间进行了实验,得到了如下的结果
这里写图片描述可以看到,倘若休眠时间与重试成功率的关系,按照绿色的曲线进行分布,那么 p 点的值也不失为该方案的一个次优解。然而不同业务和操作的需求,还是有很大的不同的。

既然 SQLite 的方案不行,我们就要开始往深层探索新的可能性了。

### 1.4 SQLite 中控制并发相关的原理

SQLite是一个适配不同平台的数据库,不仅支持多线程并发,还支持多进程并发。它的核心逻辑可以分为两部分:

这里写图片描述

* **Core 层**。包括了接口层、编译器和虚拟机。通过接口传入 SQL 语句,由编译器编译SQL生成虚拟机的操作码 opcode。而虚拟机是基于生成的操作码,控制 Backend 的行为。

* **Backend 层**。由 B-Tree、Pager、OS 三部分组成,实现了数据库的存取数据的主要逻辑。

在架构最底端的 OS 层是对不同操作系统的系统调用的抽象层。它实现了一个 VFS(Virtual File System),将 OS 层的接口在编译时映射到对应操作系统的系统调用。锁的实现也是在这里进行的。

SQLite 通过两个锁来控制并发。第一个锁对应 DB 文件,通过5种状态进行管理;第二个锁对应WAL文件,通过修改一个 16-bit 的 unsigned short int 的每一个 bit 进行管理。尽管锁的逻辑有一些复杂,但此处并不需关心。这两种锁最终都落在 OS 层的 sqlite3OsLock、sqlite3OsUnlock 和 sqlite3OsShmLock 上具体实现。

它们在锁的实现比较类似。以 lock 操作在 iOS 上的实现为例:

  1. 通过 pthread_mutex_lock 进行线程锁,防止其他线程介入。然后比较状态量,若当前状态不可跳转,则返回 SQLITE_BUSY

  2. 通过 fcntl 进行文件锁,防止其他进程介入。若锁失败,则返回 SQLITE_BUSY

而 SQLite 选择 Busy Retry 的方案的原因也正是在此

**文件锁没有线程锁类似 pthread_cond_signal 的通知机制。当一个进程的数据库操作结束时,无法通过锁来第一时间通知到其他进程进行重试。因此只能退而求其次,通过多次休眠来进行尝试。**

1.5 新的方案

搞清楚了 SQLite 并发的实现,我们就是可以开始改造了。

我们知道,iOS app 是单进程的,并没有多进程并发的需求,这和 SQLite 的设计初衷是不相同的。这就给我们的优化提供了理论上的基础。在 iOS 这一特定场景下,我们可以舍弃兼容性,提高并发性。

新的方案修改为,当 OS 层进行 lock 操作时:

  1. 通过 pthread_mutex_lock 进行线程锁,防止其他线程介入。然后比较状态量,若当前状态不可跳转,则将当前期望跳转的状态,插入到一个 FIFO 的 Queue 尾部。最后,线程通过 pthread_cond_wait 进入 休眠状态,等待其他线程的唤醒。

  2. 忽略文件锁

当 OS 层的 unlock 操作结束后:

  1. 取出 Queue 头部的状态量,并比较状态是否能够跳转。若能够跳转,则通过 pthread_cond_signal_thread_np 唤醒对应的线程重试。这里写图片描述

新的方案可以在 DB 空闲时的第一时间,通知到其他正在等待的线程,最大程度地降低了空等待的时间,且准确无误。

此外,由于 Queue 的存在,当主线程被其他线程阻塞时,可以将主线程的操作“插队”到 Queue 的头部。当其他线程发起唤醒通知时,主线程可以有更高的优先级,从而降低用户可感知的卡顿

## 2. I/O 性能优化

上面介绍了多线程并发的优化,接下来将介绍 I/O 方面的优化。

### 2.1 mmap

提到 I/O 效率的提升,最容易想到的就是 mmap了,它可以减少数据从 kernel 层到 user 层的数据拷贝,从而提高效率。

SQLite 不仅支持 mmap,而且推荐使用,在大多数平台是在一定程度上默认打开的。然而早期的 iOS 版本的存在一些 bug,SQLite 在编译层就关闭了在 iOS 上对 mmap 的支持,并且后知后觉地在16年1月才重新打开。所以如果使用的 SQLite 版本较低,还需注释掉相关代码后,重新编译生成后,才可以享受上 mmap 的性能。

下图就是 SQLite 注释掉相关代码的 commit
这里写图片描述
开启 mmap 后,SQLite 性能将有所提升,但这还不够。因为它只会对 DB 文件进行了 mmap,而 WAL 文件享受不到这个优化。原因如下:

> 开启 WAL 模式后,写入的数据会先 append 到 WAL 文件的末尾。待文件增长到一定长度后,SQLite 会进行 checkpoint。这个长度默认为1000个页大小,在 iOS 上约为3.9MB。

> 而在多句柄下,对 WAL 文件的操作是并行的。一旦某个句柄将 WAL 文件缩短了,而没有一个通知机制让其他句柄进行更新 mmap 的内容。此时其他句柄若使用 mmap 操作已被缩短的内容,就会造成 crash。而普通的 I/O 接口,则只会返回错误,不会造成 crash。因此,SQLite 没有实现对 WAL 文件的 mmap。

> 显然 SQLite 的设计是针对容量较小的设备,尤其是在十几年前的那个年代,这样的设备并不在少数。而随着硬盘价格日益降低,对于像 iPhone 这样的设备,几 MB 的空间已经不再是需要斤斤计较的了。

> 另一方面,文件重新增长,对于文件系统来说,这就意味着需要消耗时间重新寻找合适的文件块。

**权衡两者,我们可以改为**

  1. 数据库关闭并 checkpoint 成功时,不再 truncate 或删除 WAL 文件,只修改 WAL 的文件头的 Magic Number。下次数据库打开时, SQLite 会识别到 WAL 文件不可用,重新从头开始写入。

  2. 为 WAL 添加 mmap 的支持 有了上面两个优化,整体性能就会提升不少了。

这里我没有贴具体代码需要改哪些地方,一方面是因为改动点较零散,另一方面是代码上的改动并不难。这个优化的工作量主要是在 SQLite 原理和优化点的挖掘上了,大家可以根据优化方案去尝试。

## 3. 其他优化

不过我们还有一些简单易行且效果还不错的小优化,希望可以成为大家打开 SQLite 黑盒的一个契机。

### 3.1 禁用文件锁

如我们在多线程优化时所说,对于 iOS app 并没有多进程的需求。因此我们可以直接注释掉 os_unix.c 中所有文件锁相关的操作。也许你会很奇怪,虽然没有文件锁的需求,但这个操作耗时也很短,是否有必要特意优化呢?其实并不全然。耗时多少是比出来。

SQLite 中有 cache 机制。被加载进内存的 page,使用完毕后不会立刻释放。而是在一定范围内通过 LRU 的算法更新 page cache。这就意味着,如果 cache 设置得当,大部分读操作不会读取新的 page。然而因为文件锁的存在,本来只需在内存层面进行的读操作,不得不进行至少一次 I/O 操作。而我们知道,I/O 操作是远远慢于内存操作的。

### 3.2 禁用内存统计锁

SQLite 会对申请的内存进行统计,而这些统计的数据都是放到同一个全局变量里进行计算的。这就意味着统计前后,都是需要加线程锁,防止出现多线程问题的。

以下 SQLite 内存申请的函数可以看到,当内存统计打开时,会跑代码的第二个 if,malloc 的前后被锁保护了起来。

这里写图片描述

其实这里内存申请的量不大,并不是非常耗时的操作,但却很频繁。多线程并发时,各线程很容易互相阻塞。因为耗时很短,所以被阻塞的时间也很短暂。似乎不会有太大问题。但频繁地阻塞却意味着线程不断地切换,这是个很影响性能的操作,尤其对于单核设备。

因此,如果不需要内存统计的特性,可以通过 sqlite3_config(SQLITE_CONFIG_MEMSTATUS, 0)进行关闭。这个修改虽然不需要改动源码,但如果不查看源码,恐怕是比较难发现的。

## 4. 结语

总的来说,移动客户端数据库虽然不如后台数据库那么复杂,但也存在着不少可挖掘的技术点。

这次也只尝试了对 SQLite 原有的方案进行优化,而市面上还有许多优秀的数据库,如 LevelDB、RocksDB、Realm 等,它们采用了和 SQLite 不同的实现原理。后续我们将借鉴它们的优化经验,尝试更深入的优化。

以上就是我今天的分享,谢谢大家。

## 问答环节

### Q1 :前一阵微信提示我微信数据文件发现有损坏,这个是什么原因呢?

> 这个是数据库损坏,SQLite 是以B树结构存储的,如果某一个节点发生损坏,可能导致无法读取数据。损坏的原因多种多样,如断电、文件系统错误、硬盘损坏等。据我所知很多产品都出现了类似问题。

> 你看到的那个是微信的损坏监测和修复逻辑,我们做了自研的工具进行修复。这块我们后续也会分享 db 损坏的监测、保护、修复方案的

### Q2 :请问 sqlite 有时候会出 signal 11的错误,可能是什么原因导致的

> signal 11 就是 SQLITE_CORRUPT,上面提到的数据库损坏的其中一种。另一种是26 SQLITE_NOTADB

### Q3 :请问微信在全文索引上有实践吗?有没有自己做本地的搜索索引

> SQLite 是支持有全文索引的支持的,我们要做的是提供一个好的,支持中文的分词器。

### Q4 :请问微信在 db 文件修复上有什么心得呢?

> 看来大家对 db 文件损坏很关注啊。SQLite 提供了 PRAGMA integrity_check 的工具检测损坏 和 DUMP 工具导出损坏 db。但从实践来看,效果并不理想。我们采用了按 BTree 结构遍历修复的方式,以后有机会可以分享给大家

### Q5 :目前有没有已有的优化过的 sqlite 框架可供使用呢?

> iOS上SQLite 的框架似乎只有 FMDB 和 CoreData,坦白说两个都不是很好。我们是自己封装的 WCDB 框架。

### Q6 :微信的 orm 是怎么搞的

> 通过封装和规范来处理 ORM

### Q7 :请问下多句柄怎么开启,是修改 sqlite 源码后再编译的吗?

> 这个最开始有提到了

> 开启句柄多线程支持的配置 PRAGMA SQLITE_THREADSAFE=2

> 确保同一个句柄同一时间只有一个线程在操作

### Q8 :微信是怎么分析它的锁竞争的?

> 最重要的是读懂源码。辅助手段可以有 SQLite 官方的 Technical/Design Document 和 Instrument 工具

### Q9 :请问有没有对能耗的监测和优化经验?

> 检测相关的我们有卡顿监控系统,可以到我们的公众号 WeMobileDev 上了解

### Q10 :请问 sqlite 优化后有性能对比数据吗,差别有多大?

> 性能数据我以我们的卡顿系统为准,多线程并发优化使得卡顿率从4.08%降至0.19,I/O 优化使得读卡顿从1.50%降至0.20%,写卡顿从1.18%降至0.21%

### Q11 :iOS 客户端用操作数据库需要每次先 open,执行完了再 close,每次都这样,还是 app 只需要开关一次比较好呢?

> 常用的 db 没有必要经常开关,db 占用的内存并不高,可以权衡一下

### Q12 :微信对于本地空间不足会有一个强提醒,这是出于什么考虑?不同机型有不同的策略吗?

> 空间不足是个硬伤,所谓巧妇难为无米之炊。如16GB 的 iPhone,其实很影响正常使用了。不同机型会做细化

### Q13 :请问 sqlite 多线程机制,大概能应付多大量级的数据库操作(基本无卡顿),微信有这方面的测试体验吗,然后是使用了底层代码修改多线程机制后,有大概的提升量级吗?

> 优化的效果我们是以卡顿系统检测到的为准的。能否减少用户感知到的卡顿,优化用户体验才是重点,而不在于能承受多大的量级

### Q14 :微信对于数据库升级有没有特别优化的地方?或者说不同版本的跳版本升级

> 不知道这个问题指的是 SQLite 的升级还是表结构的升级。前者的话,暂时没看到 SQLite 新版本有比较大的特性值得我们跟进。后者可以用 alter table 在封装层支持升级,性能损耗不大

### Q15 :请问微信的 SQLite 有没有开启加密?如果有,性能是否有提升空间?

> iOS 版本目前没有开启加密

### Q16 :微信 sqllite 数据库用的内存数据库吗?那和文件数据库导入导出怎么控制的?

> 没有使用内存数据库

### Q17 :可以问一下,目前做 iOS 版,没有针对 android 版么?

> 这次分享的大部分内容,对Android也是通用的,触类旁通即可。

### Q18 :请问下,句柄开几个比较合适?读写分离开来对性能是否会有提升呢?

> 我们是按需生成新句柄的,并设了上限,若超过上限会有报警。如果同一时间并发量太大的话,其实更多要考虑业务层是否适用得当。至于业务层的使用,若能做细化那自然是更好

PC微信逆向--定位sqlite3_exec和数据库句柄 写在前面 最近在做PC端微信逆向,搞定了基本的首发消息,通讯录获取等,这期间遇到一个小小的问题,从通讯录获取到的内容不全,除非登录后手动点击过某个好友,不然获取不到头像、V3等,所以产生了解密数据库的想法。 收集资料 首先要明确一个想法,这个世界很大,想做的事情,至少90%都可能是别人做过的,有些人会把他们的经历分享到互联网上,我们可以利用这些知识,让自己不需要从0开始。 在这个日新月异的时代,技术存在时效性,找到的资料可以作为参考,我们需要做的,是更新替换那些过期的内容,让别人分享的东西在重新跑起来。 下 阅读详情

相关推荐

【腾讯Bugly干货分享】微信iOS SQLite源码优化实践

本文来自于腾讯bugly开发者社区,非经作者同意,请勿转载,原文地址:http://dev.qq.com/topic/57b58022433221be01499480 作者:张三华 前言随着微信iOS客户端业务的增长,在数据库上遇到的性能瓶颈也逐渐凸显。在微信的卡顿监控系统上,数据库相关的卡顿不断上升。而在用户侧也逐渐能感知到这种卡顿,尤其是有大量群聊、联系人和消息收发的重度用户。我们在对SQL

腾讯Bugly的专栏 2436

支持微信分词mmicu全文搜索的sqlite数据库工具

支持微信分词算法mmicu,icu分词的sqlite工具。可以较好的进行全文搜索FullTextSearch

微信 SQLite 数据库修复实践

1、前言众所周知,微信在后台服务器不保存聊天记录,微信在移动客户端所有的聊天记录都存储在一个 SQLite 数据库中,一旦这个数据库损坏,将会丢失用户多年的聊天记录。而我们监控到现网的损坏率是0.02%,也就是每 1w 个用户就有 2 个会遇到数据库损坏。考虑到微信这么庞大的用户基数,这个损坏率就很严重了。更严重的是我们用的官方修复算法,修复成功率只有 30%。损坏率高,修复率低,这两个问题都需要我

腾讯Bugly的专栏 5424

微信数据库查看工具 sqlcipher

安卓版微信数据库经过加密处理,可以通过此工具查看微信的数据库。 安卓版微信数据库经过加密处理,可以通过此工具查看微信的数据库。 安卓版微信数据库经过加密处理,可以通过此工具查看微信的数据库。

iOS 微信数据库分析

原文地址:http://www.jianshu.com/p/07a8d87e698b 1. 怎么得到这些文件? 过去,我可以提示用户在越狱之后用 iTools 自行把微信 App 所在文件夹复制出来。然而自从某个版本的 iOS 开始,在不越狱的情况下,我们只能看到 /User/Media 这里的文件,而需要的本地数据在 /User/Containers/Data/Application

qq471656957的专栏 7892

14 | 存储优化(下):数据库SQLite的使用和优化

我们先来复习一下前面讲到的存储方法的使用场景:少量的 Key Value 数据可以直接使用 SharedPreferences,稍微复杂一些的数据类型也可以通过序列化成 JSON 或者 Protocol Buffers 保存,并且在开发中获取或者修改数据也很简单。 不过这几种方法可以覆盖所有的存储场景吗?数据量在几百上千条这个量级时它们的性能还可以接受,但如果是几万条的微信聊天记录呢?而且如何实现快速地对某几个联系人的数据做增删改查呢? 对于大数据的存储场景,我们需要考虑稳定性、性能和可扩展性,这个时候就.

ChinaDragon10的博客 3591

【个人开发】通过SQLite获取微信聊天记录

之前都知道微信聊天记录备份的数据在本地的db文件里,但是一直访问不了db文件,今天拜读了简书上大佬的文章,有点醍醐灌顶,原因是db文件的访问需要密钥。再一次尝试,竟然成了。先上效果:数据成功读取,后面python啥的,都好处理了。

weixin_44505713的博客 1万+

【腾讯Bugly干货分享】微信iOS SQLite源码优化实践分享实录

SQLite微信iOS选用的数据库,随着微信iOS客户端业务的增长,在重度用户的场景下,性能瓶颈逐渐显现。靠单纯地修改SQLite的参数配置,已经不能彻底解决问题,因此我们尝试从源码开始做深入的优化

腾讯Bugly的专栏 4291

【腾讯Bugly干货分享】微信 iOS SQLite 源码优化实践

本文来自于腾讯bugly开发者社区,非经作者同意,请勿转载,原文地址:http://dev.qq.com/topic/57b58... 作者:张三华 前言 随着微信iOS客户端业务的增长,在数据库上遇到的性能瓶颈也逐渐凸显。在微信的卡顿监控系统上,数据库相关的卡顿不断上升。而在用户侧也逐渐能感知到这种卡顿,尤其是有大量群聊、联系人和消息收...

weixin_33812433的博客 227

【Dev Club 分享第七期】微信iOS SQLite源码优化实践

Dev Club 是一个交流移动开发技术,结交朋友,扩展人脉的社群,成员都是经过审核的移动开发工程师。每周都会举行嘉宾分享,话题讨论等活动。 本期,我们邀请了腾讯WXG iOS 开发工程师——张三华,为大家分享《微信iOS SQLite源码优化实践》。 分享内容简介:SQLite微信iOS选用的数据库,随着微信iOS客户端业务的增长,在重度用户的场景下,性能瓶颈逐渐显现。靠单纯地

fishmai的专栏 768

微信数据库解密工具 EnMicroMsg Decrypt 20200620

微信数据库 EnMicroMsg.db 解密工具 EnMicroMsg_Decrypt 20200620 Android版微信的数据库是加密了的,本工具可根据提供的密码将数据库解密为明文数据库,方便用户浏览。 用法:EnMicroMsg_Decrypt EnMicroMsg.db plaintext.db 密码 EnMicroMsg.db为待解密的数据库 plaintext.db为生成的已解密数据库 注意:使用前请先下载安装 vc2015_redist.x86.exe 运行库。 bugfix:修复旧版本无法打开中文路径内数据库文件问题。 bugfix:修复旧版本因系统缺少libeay32.dll无法运行的问题。

微信移动客户端数据存储优化实践.pdf

微信移动客户端数据存储优化实践.pdf

SQLite简单优化之一:源码优化

一:多线程并发优化(Busy Retry 的优化) 开启配置PRAGMA SQLITE_THREADSAFE=2 确保同一个句柄同一时间只有一个线程在操作 二:I/O 性能优化(保留 WAL 文件大小,mmap 优化) 配置PRAGMA mmap_size=XXX即可开启mmap 在WAL文件打开时,用unixMapfile将其映射到内存中,SQLite的OS层即会自动识别,将普通的I/O...

DongStone的专栏 832

微信全文搜索优化之路

前言基于本地数据的全文搜索(Full-Text-Search,简称 FTS)在移动应用上扮演着重要角色,与基于服务端提供的搜索服务不同,移动端受硬件条件限制,尤其在数据量相对较大的情况下,搜索性能问题表现得十分突出。本文以移动平台广泛采用的 SQLite FTS Extension 为例,介绍了移动平台 FTS 的基本原理,并结合微信 Android 客户端自身实践,重点讲述微信在 FTS 上的一

mobilehub 1万+

SQLite线程模式探讨

背景 最近微信 iOS 团队发表了一篇文章《微信iOS SQLite源码优化实践》,该文章介绍了微信 iOS 客户端对 SQLite 进行的源码层级的优化,以及其所取得的成果。优化点包括: 多线程并发优化(Busy Retry 的优化)I/O 性能优化(保留 WAL 文件大小,mmap 优化)其他优化(禁用文件锁,禁用内存统计锁) 其中,单是禁用内存统计锁这点优化,就取得了非

窃笑的博客 607
上一篇: iOS应用UI线程卡顿监控
下一篇: YYImage显示指定区域的webP图片
Philm_iOS
博客等级 码龄8年 74粉丝 53原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值