数据库连接池相关-druid相关介绍及源码分析

各种数据库连接池对比

主要功能对比

        

 

Druid

BoneCP

DBCP

C3P0

Proxool

JBoss

Tomcat-Jdbc

LRU

?

PSCache

PSCache-Oracle-Optimized

ExceptionSorter

更新维护

?

# LRU LRU是一个性能关键指标,特别Oracle,每个Connection对应数据库端的一个进程,如果数据库连接池遵从LRU,有助于数据库服务器优化,这是重要的指标。在测试中,Druid、DBCP、Proxool是遵守LRU的。BoneCP、C3P0则不是。BoneCP在mock环境下性能可能好,但在真实环境中则就不好了。

PSCache

PSCache(PrepareStatementCache预编译)是数据库连接池的关键指标。在Oracle中,类似SELECT NAME FROM USER WHERE ID = ?这样的SQL,启用PSCache和不启用PSCache的性能可能是相差一个数量级的。Proxool是不支持PSCache的数据库连接池,如果你使用Oracle、SQL Server、DB2、Sybase这样支持游标的数据库,那你就完全不用考虑Proxool。

PSCache-Oracle-Optimized

Oracle 10系列的Driver,如果开启PSCache,会占用大量的内存,必须做特别的处理,启用内部的EnterImplicitCache等方法优化才能够减少内存的占用。这个功能只有DruidDataSource有。如果你使用的是Oracle Jdbc,你应该毫不犹豫采用DruidDataSource。

ExceptionSorter

ExceptionSorter是一个很重要的容错特性,如果一个连接产生了一个不可恢复的错误,必须立刻从连接池中去掉,否则会连续产生大量错误。这个特性,目前只有JBossDataSource和Druid实现。Druid的实现参考自JBossDataSource,经过长期生产反馈补充。

连接池对比:

彻底死掉的C3P0

C3P0是我使用的第一款数据库连接池,在很长一段时间内,它一直是Java领域内数据库连接池的代名词,当年盛极一时的Hibernate都将其作为内置的数据库连接池,可以业内对它的稳定性还是认可的。C3P0功能简单易用,稳定性好这是它的优点,但是性能上的缺点却让它彻底被打入冷宫。C3P0的性能很差,差到即便是同时代的产品相比它也是垫底的,更不用和Druid、HikariCP等相比了。正常来讲,有问题很正常,改就是了,但c3p0最致命的问题就是架构设计过于复杂,让重构变成了一项不可能完成的任务。随着国内互联网大潮的涌起,性能有硬伤的c3p0彻底的退出了历史舞台。

咸鱼翻身的DBCP

DBCP(DataBase Connection Pool)属于Apache顶级项目Commons中的核心子项目(最早在Jakarta Commons里就有),在Apache的生态圈中的影响里十分广泛,比如最为大家所熟知的Tomcat就在内部集成了DBCP,实现JPA规范的OpenJPA,也是默认集成DBCP的。但DBCP并不是独立实现连接池功能的,它内部依赖于Commons中的另一个子项目Pool,连接池最核心的“池”,就是由Pool组件提供的,因此,DBCP的性能实际上就是Pool的性能,

可以看到,因为核心功能依赖于Pool,所以DBCP本身只能做小版本的更新,真正大版本的更迭则完全依托于pool。有很长一段时间,pool都还是停留在1.x版本,这直接导致DBCP也更新乏力。很多依赖DBCP的应用在遇到性能瓶颈之后,别无选择,只能将其替换掉,DBCP忠实的拥趸tomcat就在其tomcat 7.0版本中,自己重新设计开发出了一套连接池(Tomcat JDBC Pool)。好在,在2013年事情终于迎来转机,13年9月Commons-Pool 2.0版本发布,14年2月份,DBCP也终于迎来了自己的2.0版本,基于新的线程模型全新设计的“池”让DBCP重焕青春,虽然和新一代的连接池相比仍有一定差距,但差距并不大,DBCP2.x版本已经稳稳达到了和新一代产品同级别的性能指标(见下图)。

DBCP终于靠Pool咸鱼翻身,打了一个漂亮的翻身仗,但长时间的等待已经完全消磨了用户的耐心,与新一代的产品项目相比,DBCP没有任何优势,试问,谁会在有选择的前提下,去选择那个并不优秀的呢?也许,现在还选择DBCP2的唯一理由,就是情怀吧。

性能无敌的HikariCP

HikariCP号称“性能杀手”(It’s Faster),它的表现究竟如何呢,先来看下官网提供的数据:

不光性能强劲,稳定性也不差:

那它是怎么做到如此强劲的呢?官网给出的说明如下:

  • 字节码精简:优化代码,直到编译后的字节码最少,这样,CPU缓存可以加载更多的程序代码;

  • 优化代理和拦截器:减少代码,例如HikariCP的Statement proxy只有100行代码;

  • 自定义数组类型(FastStatementList)代替ArrayList:避免每次get()调用都要进行range check,避免调用remove()时的从头到尾的扫描;

  • 自定义集合类型(ConcurrentBag):提高并发读写的效率;

  • 其他缺陷的优化,比如对于耗时超过一个CPU时间片的方法调用的研究(但没说具体怎么优化)。

可以看到,上述这几点优化,和现在能找到的资料来看,HakariCP在性能上的优势应该是得到共识的,再加上它自身小巧的身形,在当前的“云时代、微服务”的背景下,HakariCP一定会得到更多人的青睐。

功能全面的Druid

近几年,阿里在开源项目上动作频频,除了有像fastJson、dubbo这类项目,更有像AliSQL这类的大型软件,今天说的Druid,就是阿里众多优秀开源项目中的一个。它除了提供性能卓越的连接池功能外,还集成了SQL监控,黑名单拦截等功能,用它自己的话说,Druid是“为监控而生”。借助于阿里这个平台的号召力,产品一经发布就赢得了大批用户的拥趸,从用户使用的反馈来看,Druid也确实没让用户失望。

相较于其他产品,Druid另一个比较大的优势,就是中文文档比较全面(毕竟是国人的项目么),在github的wiki页面,列举了日常使用中可能遇到的问题,对一个新用户来讲,上面提供的内容已经足够指导它完成产品的配置和使用了。

现在项目开发中,我还是比较倾向于使用Durid,它不仅仅是一个数据库连接池,它还包含一个ProxyDriver,一系列内置的JDBC组件库,一个SQL Parser。

Druid 相对于其他数据库连接池的优点

  1. 强大的监控特性,通过Druid提供的监控功能,可以清楚知道连接池和SQL的工作情况。a. 监控SQL的执行时间、ResultSet持有时间、返回行数、更新行数、错误次数、错误堆栈信息;b. SQL执行的耗时区间分布。什么是耗时区间分布呢?比如说,某个SQL执行了1000次,其中01毫秒区间50次,110毫秒800次,10100毫秒100次,1001000毫秒30次,1~10秒15次,10秒以上5次。通过耗时区间分布,能够非常清楚知道SQL的执行耗时情况;c. 监控连接池的物理连接创建和销毁次数、逻辑连接的申请和关闭次数、非空等待次数、PSCache命中率等。

  2. 方便扩展。Druid提供了Filter-Chain模式的扩展API,可以自己编写Filter拦截JDBC中的任何方法,可以在上面做任何事情,比如说性能监控、SQL审计、用户名密码加密、日志等等。

  3. Druid集合了开源和商业数据库连接池的优秀特性,并结合阿里巴巴大规模苛刻生产环境的使用经验进行优化。

数据库连接池技术带来的优势:

1. 资源重用

由于数据库连接得到重用,避免了频繁创建、释放连接引起的大量性能开销。在减少系统消耗的基础上,另一方面也增进了系统运行环境的平稳性(减少内存碎片以及数据库临时进程/线程的数量)。

2. 更快的系统响应速度

数据库连接池在初始化过程中,往往已经创建了若干数据库连接置于池中备用。此时连接的初始化工作均已完成。对于业务请求处理而言,直接利用现有可用连接,避免了数据库连接初始化和释放过程的时间开销,从而缩减了系统整体响应时间。

3. 新的资源分配手段

对于多应用共享同一数据库的系统而言,可在应用层通过数据库连接的配置,实现数据库连接池技术。某一应用最大可用数据库连接数的限制,避免某一应用独占所有数据库资源。

4. 统一的连接管理,避免数据库连接泄漏

在较为完备的数据库连接池实现中,可根据预先的连接占用超时设定,强制收回被占用连接。从而避免了常规数据库连接操作中可能出现的资源泄漏。

 

更少的内存空间和时间的消耗:

 

不使用数据库连接池的步骤:

TCP建立连接的三次握手

MySQL认证的三次握手

真正的SQL执行

MySQL的关闭

TCP的四次握手关闭

可以看到,为了执行一条SQL,却多了非常多我们不关心的网络交互。

 

 

第一次访问的时候,需要建立连接。 但是之后的访问,均会复用之前创建的连接,直接执行SQL语句。

优点:

  1. 较少了网络开销

  2. 系统的性能会有一个实质的提升

  3. 没了麻烦的TIME_WAIT状态

 

连接池的工作原理:

连接池技术的核心思想是连接复用,通过建立一个数据库连接池以及一套连接使用、分配和管理策略,使得该连接池中的连接可以得到高效、安全的复用,避免了数据库连接频繁建立、关闭的开销。

连接池的工作原理主要由三部分组成,分别为连接池的建立、连接池中连接的使用管理、连接池的关闭。

第一、连接池的建立。一般在系统初始化时,连接池会根据系统配置建立,并在池中创建了几个连接对象,以便使用时能从连接池中获取。连接池中的连接不能随意创建和关闭,这样避免了连接随意建立和关闭造成的系统开销。Java中提供了很多容器类可以方便的构建连接池,例如Vector、Stack等。

第二、连接池的管理。连接池管理策略是连接池机制的核心,连接池内连接的分配和释放对系统的性能有很大的影响。其管理策略是:

  • 当客户请求数据库连接时,首先查看连接池中是否有空闲连接,如果存在空闲连接,则将连接分配给客户使用;如果没有空闲连接,则查看当前所开的连接数是否已经达到最大连接数,如果没达到就重新创建一个连接给请求的客户;如果达到就按设定的最大等待时间进行等待,如果超出最大等待时间,则抛出异常给客户。

  • 当客户释放数据库连接时,先判断该连接的引用次数是否超过了规定值,如果超过就从连接池中删除该连接,否则保留为其他客户服务。该策略保证了数据库连接的有效复用,避免频繁的建立、释放连接所带来的系统资源开销。

第三、连接池的关闭。当应用程序退出时,关闭连接池中所有的连接,释放连接池相关的资源,该过程正好与创建相反。

 

通过继承树可以看到继承了好的类和接口,其中主要的有两个线,DruidAbstractDataSource和CommonDataSource。说明DruidDataSrouce是一个DataSource,可以getConnection获取连接。

 

 

源码分析:

 

public class DruidDataSource {

// 默认使用公平锁

private ReentrantLock lock = new ReentrantLock(false);

 

private Condition notEmpty = lock.newCondition();

 

private Condition empty = lock.newCondition();

 

public Connection getConnection() {

lock.lock;

if ( "池中有连接" ) {

// 从池中取出连接

} else {

empty.signal(); // 唤醒创建连接的线程进行创建连接

notEmpty.await(); // 等创建连接的线程进行通知

}

lock.unlock();

}

 

// 调用Connection.close(),会调用DruidDataSource的recycle方法

public void recycle( Connection conn ) {

// 对连接进行检测

lock.lock;

putLast();

lock.unlock();

}

 

public CreateThread extends Thread {

public void run() {

for (;;) {

lock.lockInterruptibly();

if ( "池中有可用连接" ) {

empty.await(); // 进入waiting,等等获取连接的线程唤醒

} else {

//TODO 创建连接

notEmpty.signal(); // 唤醒等等连接的线程

}

lock.unlock();

}

}

}

public DestoryThread extends Thread {

public void run() {

for (;;) {

lock.lockInterruptibly();

// 清理连接池内的过期连接

// (可选)清理从连接池拿走,正在使用的连接

lock.unlock();

Thread.sleep( times );

}

}

}

}

初始化

每次获取Connection都会调用init,内部使用inited标识DataSource是否已经初始化OK,init主要完成以下工作:

- 初始化Filter,这些Filter可以嵌入各个环节,包括创建、销毁链接,提交、回滚事务等等,比如常见的ConfigFilter(支持密码加密)、StatFilter(监控,比如打印慢查询SQL)、LogFilter(打印各种日志)。依次从wrapper jdbcUrl、setFilters指定的Filters、SPI加载Filter并进行初始化

  • 加载数据库驱动Driver

  • 根据不同的数据库,实例化ExceptionSorter,主要的api就是isExceptionFatal(SQLException e),用于判断是否是Fatal级别的异常

  • 初始化连接检测器,不同数据库的实现不一样,比如mysql是调用pingInternal检测连接是否OK。ValidConnectionChecker在获取连接、回收连接的时候会用到

  • 初始化JdbcDataSourceStat,主要目的是做监控

  • 初始化connections、evictConnections、keepAliveConnections数组,分别用于存放可被获取的连接池、待清理的连接池、存活的连接池,数组的大小都是maxActive

  • 初始化initialSize个Connection

  • 开启LogStatsThread线程,用于定期打印DruidDataSource的一些数据,默认是不开启的,需要开启的话只需要设置timeBetweenLogStatsMillis指定打印的时间周期,log步骤需要获取主锁,建议时间不要设得太短

  • 创建CreateConnectionThread线程,druid内部默认使用一个线程异步地创建连接,当然可以指定createScheduler线程池,开启多个线程创建连接,但是请把keepAlive设为true,否则不会开启异步线程创建连接

  • 创建DestroyConnectionThread线程,定期扫描连接池内过期的连接,如果想对连接池外面正在使用的连接也进行清理的话,需要指定removeAbandoned为true,清理线程会判断连接是否正在使用,是否超过了清理时间而进行清理

  •  

public void init() throws SQLException {

// 由volatite修改,每次获取连接,也会调用init()

if (inited) {

return;

}

// 可以被中断的lock操作

final ReentrantLock lock = this.lock;

try {

lock.lockInterruptibly();

} catch (InterruptedException e) {

throw new SQLException("interrupt", e);

}

boolean init = false;

try {

if (inited) {

return;

}

// 获取程序的调用栈,标注由哪个函数调用的init方法

initStackTrace = Utils.toString(Thread.currentThread().getStackTrace());

this.id = DruidDriver.createDataSourceId();

if (this.id > 1) {

long delta = (this.id - 1) * 100000;

this.connectionIdSeed.addAndGet(delta);

this.statementIdSeed.addAndGet(delta);

this.resultSetIdSeed.addAndGet(delta);

this.transactionIdSeed.addAndGet(delta);

}

// 这个地方用于支持wrapper jdbcUrl,可以使用filter=xxx,jmx=xxx这种方式,具体请参考官方文档

// 比如使用jdbc:wrap-jdbc:filters=stat,log4j:jdbc:mysql:xxx这种方式可以配置Filter用于收集监控信息

// 具体请参考源码 DruidDriver.parseConfig(jdbcUrl, null)

if (this.jdbcUrl != null) {

this.jdbcUrl = this.jdbcUrl.trim();

initFromWrapDriverUrl();

}

// 初始化Filter

for (Filter filter : filters) {

filter.init(this);

}

// 各种参数校验,other code......

// 从SPI中加载Filter,如果前面加载的Filter不存在则还需要进行初始化,可以指定系统参数druid.load.spifilter.skip=false禁用该SPI

initFromSPIServiceLoader();

// 初始化Driver实例

if (this.driver == null) {

if (this.driverClass == null || this.driverClass.isEmpty()) {

this.driverClass = JdbcUtils.getDriverClassName(this.jdbcUrl);

}

// 用于支持com.alibaba.druid.mock.MockDriver

if (MockDriver.class.getName().equals(driverClass)) {

driver = MockDriver.instance;

} else {

driver = JdbcUtils.createDriver(driverClassLoader, driverClass);

}

} else {

if (this.driverClass == null) {

this.driverClass = driver.getClass().getName();

}

}

// 针对不同数据库的一些校验逻辑

initCheck();

// 初始化ExceptionSorter

initExceptionSorter();

// 初始化连接检测器,不同数据库的实现不一样,比如mysql是调用pingInternal检测连接是否OK

initValidConnectionChecker();

validationQueryCheck();

// 初始化DataSource的监控器

if (isUseGlobalDataSourceStat()) {

dataSourceStat = JdbcDataSourceStat.getGlobal();

if (dataSourceStat == null) {

dataSourceStat = new JdbcDataSourceStat("Global", "Global", this.dbType);

JdbcDataSourceStat.setGlobal(dataSourceStat);

}

if (dataSourceStat.getDbType() == null) {

dataSourceStat.setDbType(this.dbType);

}

} else {

dataSourceStat = new JdbcDataSourceStat(this.name, this.jdbcUrl, this.dbType, this.connectProperties);

}

dataSourceStat.setResetStatEnable(this.resetStatEnable);

// 连接池中可用的连接(未被拿走),内部会维护一个poolingCount值代表队列中剩余可用的连接,每次从末尾拿走连接

connections = new DruidConnectionHolder[maxActive];

// 失效、过期的连接,会暂时放在这个数组里面

evictConnections = new DruidConnectionHolder[maxActive];

// 销毁线程会检测线程,如果检测存活的线程放暂时放在这里,然后统一放入connections中

keepAliveConnections = new DruidConnectionHolder[maxActive];

SQLException connectError = null;

// 是否异常初始化连接池,如果不是的话,则初始化指定的initialSize个连接数

boolean asyncInit = this.asyncInit && createScheduler == null;

if (!asyncInit) {

try {

// init connections

for (int i = 0, size = getInitialSize(); i < size; ++i) {

PhysicalConnectionInfo pyConnectInfo = createPhysicalConnection();

DruidConnectionHolder holder = new DruidConnectionHolder(this, pyConnectInfo);

connections[poolingCount] = holder;

incrementPoolingCount();

}

if (poolingCount > 0) {

poolingPeak = poolingCount;

poolingPeakTime = System.currentTimeMillis();

}

} catch (SQLException ex) {

LOG.error("init datasource error, url: " + this.getUrl(), ex);

connectError = ex;

}

}

/** 初始化必须的线程 */

createAndLogThread(); // 开启logger日志打印的线程

createAndStartCreatorThread(); // 开启创建连接的线程,如果线程池createScheduler为null,则开启单个创建连接的线程

createAndStartDestroyThread(); // 开启销毁过期连接的线程

 

// 等待前面的线程初始化完成

initedLatch.await();

init = true;

 

initedTime = new Date();

 

// 初始DataSource注册到jmx中

registerMbean();

 

if (connectError != null && poolingCount == 0) {

throw connectError;

}

 

// keepAlive为true时,并且createScheduler不为null,则初始化minIdle个线程用于创建连接

if (keepAlive) {

// async fill to minIdle

if (createScheduler != null) {

for (int i = 0; i < minIdle; ++i) {

createTaskCount++;

CreateConnectionTask task = new CreateConnectionTask();

this.createSchedulerFuture = createScheduler.submit(task);

}

} else {

this.emptySignal();

}

}

}

// catch各种异常,进行日志打印,并抛出异常

catch (Error e) {

LOG.error("{dataSource-" + this.getID() + "} init error", e);

throw e;

}

finally {

inited = true;

lock.unlock();

 

// 省略日志输出代码

}

}

 

分为以下几个动作:

  • 调用getConnectionInternal获取经过各种包装的Connection,这个是获取连接的主要逻辑,支持超时时间,由DruidDataSource的maxWait参数指定,单位毫秒

  • 如果testOnBorrow为true,则进行对连接进行校验,校验失败则进行清理并重新进入循环,否则跳到下一步

  • 如果testWhileIdle为true,距离上次激活时间超过timeBetweenEvictionRunsMillis,则进行清理

  • 如果removeAbandoned为true,则会把连接存放在activeConnections中,清理线程会对其定期进行处理

 

public DruidPooledConnection getConnectionDirect(long maxWaitMillis) throws SQLException {

int notFullTimeoutRetryCnt = 0;

 

// 循环

for (;;) {

// handle notFullTimeoutRetry

DruidPooledConnection poolableConnection;

try {

poolableConnection = getConnectionInternal(maxWaitMillis);

} catch (GetConnectionTimeoutException ex) {

// logger error info

throw ex;

}

 

// 如果testOnBorrow设为true的话,则在返回连接之前,需要进行校验,校验的逻辑不仅仅是判断是否被关闭,还需要调用ValidConnectionChecker进行check,前面的init里面也分析过

// 需要注意的是,如果check失败,则会discard处理,并且重新走一遍循环

if (testOnBorrow) {

boolean validate = testConnectionInternal(poolableConnection.holder, poolableConnection.conn);

if (!validate) {

Connection realConnection = poolableConnection.conn;

discardConnection(realConnection);

continue;

}

} else {

Connection realConnection = poolableConnection.conn;

if (poolableConnection.conn.isClosed()) {

discardConnection(null); // 传入null,避免重复关闭

continue;

}

 

// 如果testWhileIdle为true,并且上一次激活的时间如果超过清理线程执行的间距,则进行check动作

if (testWhileIdle) {

long currentTimeMillis = System.currentTimeMillis();

long lastActiveTimeMillis = poolableConnection.holder.lastActiveTimeMillis;

long idleMillis = currentTimeMillis - lastActiveTimeMillis;

if (idleMillis >= timeBetweenEvictionRunsMillis

|| idleMillis < 0 // unexcepted branch

) {

boolean validate = testConnectionInternal(poolableConnection.holder, poolableConnection.conn);

if (!validate) {

discardConnection(realConnection);

continue;

}

}

}

}

 

// 如果removeAbandoned设为true,则把返回的线程保存起来,便于清理线程进行清理,注意只有removeAbandoned为true清理线程才会对池外的连接进行清理

if (removeAbandoned) {

StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();

poolableConnection.connectStackTrace = stackTrace;

poolableConnection.setConnectedTimeNano();

poolableConnection.traceEnable = true;

 

// 放在activeConnections中,它是一个IdentityHashMap,个人觉得完成可以使用并发的Map,可能是考虑hashCode的问题吧

activeConnectionLock.lock();

try {

activeConnections.put(poolableConnection, PRESENT);

} finally {

activeConnectionLock.unlock();

}

}

if (!this.defaultAutoCommit) {

poolableConnection.setAutoCommit(false);

}

return poolableConnection;

}

}

getConnectionInternal

这一块没啥好分析的,主要是控制了创建连接的线程数量,以及处理异常,主要的逻辑由pollLast(nanos)或者takeLast()完成

 

druid的代理与责任链:

 

druid的对各个sql的接口都进行了代理,进行实现统计或者功能。通过源码可以看到,如果配置了责任链的节点就会使用代理类,如果没有使用则不会使用代理。

 

 

 

如何配置连接数?

https://github.com/alibaba/druid/wiki/DruidDataSource%E9%85%8D%E7%BD%AE

testOnBorrow: https://www.jianshu.com/p/edb6a91285be

testWhileIdle:https://www.jianshu.com/p/bdf96b3fd743

连接数 = ((核心数 * 2) + 有效磁盘数)

好了,按照这个公式,如果说你的服务器 CPU 是 4核 i7 的,连接池大小应该为 ((4 * 2) + 1) = 9

取个整, 我们就设置为 10 吧。你这个行不行啊?10 也太小了吧!

你要是觉得不太行的话,可以跑个性能测试看看,我们可以保证,它能轻松支撑 3000 用户以 6000 TPS 的速率并发执行简单查询的场景。你还可以将连接池大小超过 10,那时,你会看到响应时长开始增加,TPS 开始下降。

总结

时至今日,虽然每个应用(需要RDBMS的)都离不开连接池,但在实际使用的时候,连接池已经可以做到“隐形”了。也就是说在通常情况下,连接池完成项目初始化配置之后,就再不需要再做任何改动了。不论你是选择Druid或是HikariCP,甚至是DBCP,它们都足够稳定且高效!之前讨论了很多关于连接池的性能的问题,但这些性能上的差异,是相较于其他连接池而言的,对整个系统应用来说,第二代连接池在使用过程中体会到的差别是微乎其微的,基本上不存在因为连接池的自身的配饰和使用导致系统性能下降的情况,除非是在单点应用的数据库负载足够高的时候(压力测试的时候),但即便是如此,通用的优化的方式也是单点改集群,而不是在单点的连接池上死扣。

 

 

 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值