【Mysql】动态数据源切换和分布式事务

一、动态数据源切换

    再谈到动态数据源切换之前,我们先看一下常规的单数据库连接池(数据源)的配置过程,我们以Hikari连接池(Hikari官网-高效连接池)和Mybatis框架为例说明。

①Spring Mybatis Hikari datasource 配置


    @Bean(name = "userReadHikariConfig")
    public HikariConfig userReadHikariConfig() {
        HikariConfig conf = new HikariConfig();
        conf.setJdbcUrl(readUrl);
        conf.setUsername(userName);
        conf.setPassword(password);
        conf.setPoolName("user-read-datasource");
        conf.setConnectionTimeout(connectTimeout);
        conf.setValidationTimeout(validateTimeout);
        conf.setMaximumPoolSize(maximumPoolSize);
        return conf;
    }

    @Bean(name = "userReadDataSource")
    public DataSource userReadDataSource() {
        return new HikariDataSource(userReadHikariConfig());
    }

    @Bean(name = "userReadSessionFactory")
    public SqlSessionFactory sqlSessionFactory() throws Exception {
        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(userReadDataSource());
sqlSessionFactoryBean.getObject().getConfiguration().setMapUnderscoreToCamelCase(true);
        return sqlSessionFactoryBean.getObject();
    }

如上所示,我们配置了一个用户数据库读库的连接池(一些变量赋值缺省),并构造注入了SqlSessionFactory。

SqlSessionFactory是MyBatis的关键对象,它是个单个数据库映射关系经过编译后的内存镜像。SqlSessionFactory一旦被创建,应该在应用执行期间都存在,它是创建SqlSession的工厂。

SqlSession也是MyBatis的关键对象,是执行持久化操作的独享,类似于JDBC中的Connection。它完全包含以数据库为背景的所有执行SQL操作的方法,它的底层封装了JDBC连接,可以用SqlSession实例来直接执行被映射的SQL语句。每个线程都应该有它自己的SqlSession实例,SqlSession的实例不能被共享,同时SqlSession也是线程不安全的。

②AbstractRoutingDatasource

      单个数据源配置清楚了,多个数据源只需要重复上述配置即可,如果我们需要在业务读写分离,我们如何做到动态切换数据源呢,Spring为我们提供的抽象类AbstractRoutingDatasource实现了DataSource接口的用于获取数据库连接的方法,通过AOP的方式在程序运行时动态切换数据源。

è¿éåå¾çæè¿°

具体来说怎么实现呢,首先编写AbstractRoutingDataSource的实现类DynamicDataSource,根据当前线程来选择数据源,然后通过AOP拦截特定的注解,设置当前的数据源信息。

public class DynamicDatasource extends AbstractRoutingDataSource {

    private static class DynamicDataSourceHolder {

        //使用ThreadLocal来保存当前线程需要使用的 dataSource 的 key。保证线程安全
        private static final ThreadLocal<String> holder = new ThreadLocal<>();

        private static void setDataSourceKey(String key) {
            holder.set(key);
        }

        private static String getDataSourceKey() {
            String key = holder.get();
            if (null == key) {
                key = DataSourceTypeEnum.READ.getName();   //默认写库
            }
            return key;
        }

    }

    public static void setWrite() {
        DynamicDataSourceHolder.setDataSourceKey(DataSourceTypeEnum.WRITE.getName());
    }

    public static void setRead() {
        DynamicDataSourceHolder.setDataSourceKey(DataSourceTypeEnum.READ.getName());
    }


    @Override
    protected Object determineCurrentLookupKey() {
        // 一个读库实现。多个读库时可以在这里做负载均衡
        return DynamicDataSourceHolder.getDataSourceKey();
    }

    //这个函数看似没用,在后面分布式事务的时候再说明
    public String getCurrentDatasourceKey()
    {
        return DynamicDataSourceHolder.getDataSourceKey();
    }

    public enum DataSourceTypeEnum {
        WRITE("write"),
        READ("read");

        private String name;

        DataSourceTypeEnum(String name) {
            this.name = name;
        }

        public String getName() {
            return name;
        }
    }
}

以上核心是重写determineCurrentLookupKey这个方法,可以看到它的实现是通过getDataSourceKey获取threadlocal里保存的key,而在AbstractRoutingDataSource 里通过key和datasource的map,找到对应的datasource。感兴趣的同学可以看一下AbstractRoutingDataSource这个类的源码,这里不在粘贴详述。

DynamicDataSource编写完成之后,我们用它来替换①中SqlSessionFactory构造是直接使用的HikariDatasource,具体代码如下:


    @Bean(name = "userReadHikariConfig")
    public HikariConfig userReadHikariConfig() {
        HikariConfig conf = new HikariConfig();
        conf.setJdbcUrl(readUrl);
        conf.setUsername(userName);
        conf.setPassword(password);
        conf.setPoolName("user-read-datasource");
        conf.setConnectionTimeout(connectTimeout);
        conf.setValidationTimeout(validateTimeout);
        conf.setMaximumPoolSize(maximumPoolSize);
        return conf;
    }

    @Bean(name = "userReadDataSource")
    public DataSource userReadDataSource() {
        return new HikariDataSource(userReadHikariConfig());
    }

    @Bean(name = "userWriteHikariConfig")
    public HikariConfig userWriteHikariConfig() {
        HikariConfig conf = new HikariConfig();
        conf.setJdbcUrl(writeUrl);
        conf.setUsername(userName);
        conf.setPassword(password);
        conf.setPoolName("user-write-datasource");
        conf.setConnectionTimeout(connectTimeout);
        conf.setValidationTimeout(validateTimeout);
        conf.setMaximumPoolSize(maximumPoolSize);
        return conf;
    }

    @Bean(name = "userWriteDataSource")
    public DataSource userWriteDataSource() {
        return new HikariDataSource(userWriteHikariConfig());
    }

    @Bean(name = "userDataSource")
	public AbstractRoutingDataSource userDataSource() {
        DynamicDatasource dynamicDatasource = new DynamicDatasource();
		Map<Object, Object> targetDataSources = new HashMap<>();
		targetDataSources.put(DataSourceTypeEnum.WRITE.getName(), userWriteDataSource());
		targetDataSources.put(DataSourceTypeEnum.READ.getName(), userReadDataSource());
		dynamicDatasource.setTargetDataSources(targetDataSources);
		dynamicDatasource.afterPropertiesSet();
		return dynamicDatasource;
	}

    @Bean(name = "userSessionFactory")
    public SqlSessionFactory sqlSessionFactory() throws Exception {
        SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
        //注意这里替换成我们写的DynamicDatasource
        sqlSessionFactoryBean.setDataSource(userDataSource());
sqlSessionFactoryBean.getObject().getConfiguration().setMapUnderscoreToCamelCase(true);
        return sqlSessionFactoryBean.getObject();
    }

然后,DynamicDataSource里的setWrite和setRead方法分别是往threadlocal里设置写或读数据源的key,这两个方法都是为了在aop里使用。那aop怎么实现呢?首先我们需要在mapper执行sql语句前做拦截,可以给对应方法加自定义注解拦截,也可以统一拦截所有mapper通过方法的名字是否含有query,get等决定是否用读库。下面给出第二种方式实现的代码

@Component
@Aspect
public class DataSourceAop {

    private static final Pattern READ_PATTERN = Pattern.compile("^(query|get|select|by|is|count|list|search)");

    @Before("execution(* com.example.demo.dao.users..*.*(..)) ")
    public void setDataSource(JoinPoint point) {
        String methodName = point.getSignature().getName();
        if (READ_PATTERN.matcher(methodName).find()) {
            DynamicDatasource.setRead();
        } else {
            DynamicDatasource.setWrite();
        }
    }

}

至此以来动态数据源切换(读写分离)就完成了,通过aop设置当前线程threadlocal中的表示不同库(读或写库)的key,在执行mapper获取数据库连接的时候AbstractRoutingDataSource通过key实现了选择对应的数据库链接。

二、分布式事务

随着数据库拆分,当操作数据库分布在两个不同的物理节点的时候就需要用到分布式事务,本文主要介绍一种常规的JTA+Atomikos+Mybatis的实现方式。

概念介绍

  • JTA

        是JavaEE 13 个开发规范之一。java 事务API,允许应用程序执行分布式事务处理——在两个或多个网络计算机资源上访问          并且更新数据。DBC驱动程序的JTA支持极大地增强了数据访问能力。事务最简单最直接的目的就是保证数据的有效性,数          据的一致性。可以参考JTA原理与实现

  • Atomikos

       实现JTA事务管理第三方管理工具

  • Mybatis

       MyBatis 是一款优秀的持久层框架,它支持定制化 SQL、存储过程以及高级映射。可参考Mybatis中文官方文档

  • XA协议

       XA是一个分布式事务协议,由Tuxedo提出。XA中大致分为两部分:事务管理器和本地资源管理器。其中本地资源管理器往         往由数据库实现,比如Oracle、DB2这些商业数据库都实现了XA接口,而事务管理器作为全局的调度者,负责各个本地资           源的提交和回滚。可参考XA两阶段提交协议

XA数据源

首先数据库必须都是支持xa协议的,Mysql从5.0.3开始支持XA分布式事务,业务上使用的InnoDB和TokuDB对XA分布式事务都是支持的。为支持分布式事务,不在如上使用Hikari连接池,而是使用支持分布式事务的mysql数据源,构造数据源工具类如下:

public class DataSourceUtil {

    public static DataSource getAtomikosXADataSource(String uniqueResourceName, String databaseUrl, String userName,
                                                     String password, int minPoolSize, int maxPoolSize) {
        if (StringUtils.isBlank(uniqueResourceName) || StringUtils.isBlank(databaseUrl) || StringUtils.isBlank(userName)
                || StringUtils.isBlank(password) || minPoolSize < 0 || maxPoolSize < 0) {
            return null;
        }
        MysqlXADataSource mysqlXADataSource = new MysqlXADataSource();
        mysqlXADataSource.setUrl(databaseUrl);
        mysqlXADataSource.setUser(userName);
        mysqlXADataSource.setPassword(password);
        AtomikosDataSourceBean atomikosDataSource = new AtomikosDataSourceBean();
        atomikosDataSource.setUniqueResourceName(uniqueResourceName);
        atomikosDataSource.setXaDataSource(mysqlXADataSource);
        atomikosDataSource.setMinPoolSize(minPoolSize);
        atomikosDataSource.setMaxPoolSize(maxPoolSize);
        atomikosDataSource.setTestQuery("SELECT 1");
        return atomikosDataSource;
    }
}

然后我们再次改写前面的SqlSessionFactory的构造过程,保留动态数据源切换DynamicDatasource,但是把Hikari连接池换成我们的Atomikos实现的XA Datasource,如下所示


    @Bean(name = "userWriteDataSource")
    public DataSource userWriteDataSource() {
        return DataSourceUtil.getAtomikosXADataSource("user-write-datasource", writeUrl, writeUsername,writePassword, minimumPoolSize, maximumPoolSize);
    }

    @Bean(name = "userReadDataSource")
    public DataSource userReadDataSource() {
        return DataSourceUtil.getAtomikosXADataSource("user-read-datasource", readUrl, readUsername,readPassword, minimumPoolSize, maximumPoolSize);
    }

    @Bean(name = "userDataSource")
    public AbstractRoutingDataSource userDataSource() {
        DynamicDatasource dynamicDatasource = new DynamicDatasource();

        Map<Object, Object> targetDataSources = new HashMap<>(16);
        targetDataSources.put(DynamicDatasource.DataSourceTypeEnum.WRITE.getName(), newsWriteDataSource());
        targetDataSources.put(DynamicDatasource.DataSourceTypeEnum.READ.getName(), newsReadDataSource());
        dynamicDatasource.setTargetDataSources(targetDataSources);

        dynamicDatasource.afterPropertiesSet();
        return dynamicDatasource;
    }

	@Bean(name = "userSessionFactory")
	public SqlSessionFactory sqlSessionFactory() throws Exception {
		SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
		sqlSessionFactoryBean.setDataSource(userDataSource());
		sqlSessionFactoryBean.getObject().getConfiguration().setMapUnderscoreToCamelCase(true);
		return sqlSessionFactoryBean.getObject();
	}

JTA事务管理器

数据源的问题解决后,我们利用jta和atomikos的实现,完成我们的事务管理器,代码如下:

import com.atomikos.icatch.jta.UserTransactionImp;
import com.atomikos.icatch.jta.UserTransactionManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.jta.JtaTransactionManager;

import javax.transaction.TransactionManager;
import javax.transaction.UserTransaction;

@Configuration
public class JtaTransactionConfiguration {

    @Bean
    public UserTransaction userTransaction() throws Throwable {
        UserTransactionImp userTransactionImp = new UserTransactionImp();
        userTransactionImp.setTransactionTimeout(1000);
        return userTransactionImp;
    }

    @Bean(initMethod = "init", destroyMethod = "close")
    public TransactionManager transactionManager() throws Throwable {
        UserTransactionManager transactionManager = new UserTransactionManager();
        transactionManager.setForceShutdown(false);
        return transactionManager;
    }

    @Bean(name = "myTransaction")
    public PlatformTransactionManager platformTransactionManager() throws Throwable {
        return new JtaTransactionManager(userTransaction(), transactionManager());
    }
}

其中userTransaction和transactionManager都由引入的atomikos的Jar包里实现,生成jta规范的事务管理器“myTracsaction”注入容器,至此只需要在使用事务的方法上加上注解@Transactional(value = "mpTransaction", rollbackFor = Exception.class)即可完成事务操作,如下所示:

    @Transactional(value = "myTransaction", rollbackFor = Exception.class)
    public void test() {
        userMapper.getById(10);
        userMapper.updateStatus(1);
        feedMapper.updateStatus(1);
        ...
        //其他数据源需要事物的操作
    }

三、实际问题

      实际使用过程发现了一个比较严重的问题,@Transactional会导致AbstractRoutingDataSource动态数据源无法切换。拿上文的举例说明,userMapper首先做读操作 userMapper.getById(10) 然后进行了写操作userMapper.updateStatus(1) ,在有@Transactional注解的情况下发现会报read-only异常,显然第二次写操作没有正确切换到写连接上,仍然保留使用了之前的读连接。(尽管说没有必要在事务中进行读操作,即便是需要根据读的结果做一系列事务操作,也可以将都操作提出到@Transactional注解的方法之外,但是这个问题还是值得深究一下。)

      单步跟踪调试之后,发现了问题所在,再执行Mapper操作之前,会调用SpringManagedTransaction类的getConnection方法获取数据库连接,以下是getConnection方法的源代码

public class SpringManagedTransaction implements Transaction {

  private static final Log LOGGER = LogFactory.getLog(SpringManagedTransaction.class);

  private final DataSource dataSource;

  private Connection connection;

  private boolean isConnectionTransactional;

  private boolean autoCommit;

  public SpringManagedTransaction(DataSource dataSource) {
    notNull(dataSource, "No DataSource specified");
    this.dataSource = dataSource;
  }

  /**
   * {@inheritDoc}
   */
  @Override
  public Connection getConnection() throws SQLException {
    if (this.connection == null) {
      openConnection();
    }
    return this.connection;
  }

在openConnection()方法执行之前,if语句进行了判断当前线程是否已经获得了这个资源池(数据库)的连接,如果没有@Transactional注解,在执行完第一条userMapper.getById(10)操作后连接会归还给资源池,再次执行userMapper.updateStatus(1)时,connection是null,因此还会执行openConnection(),之后会从我们配置的AbstractRoutingDataSource动态数据源获取连接,完成数据源切换。 但是一旦我们加上了@Transactional注解,将会保留这个个连接,以致于第二次对user库写操作时,并不会执行openConnection()方法,仍然使用之前的读连接,因此抛出read-only的异常。

      为什么会有这样写呢?spring事务处理的一个关键是保证在整个事务的生命周期里所有执行sql的jdbc connection和处理事务的jdbc connection始终是同一个。但是我们的二阶段提交的XA分布式事务不必如此。

      有什么好的解决方式么? 我们可以通过重写了SpringManagedTransaction类的getConnection方法来实现。示例如下:

package com.example.demo.datasource;

import org.apache.ibatis.logging.Log;
import org.apache.ibatis.logging.LogFactory;
import org.mybatis.spring.transaction.SpringManagedTransaction;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 自定义事务管理主要解决springManagedTransaction事务内不能切换数据源的问题
 */
public class MyManagedTransaction extends SpringManagedTransaction {
    private static final Log LOGGER = LogFactory.getLog(MpManagedTransaction.class);

    DataSource dataSource;
    ConcurrentHashMap<String, Connection> map = new ConcurrentHashMap<>();

    public MpManagedTransaction(DataSource dataSource) {
        super(dataSource);
        this.dataSource = dataSource;
    }

    @Override
    public Connection getConnection() throws SQLException {
        DynamicDatasource dynamicDatasource = (DynamicDatasource) dataSource;
        String key = dynamicDatasource.getCurrentDatasourceKey();
        if (map.containsKey(key)) {
            return map.get(key);
        }
        Connection con = dataSource.getConnection();
        map.put(key, con);
        return con;
    }

}

重写了getConnection方法,将connection也变成动态获取的。另外,SpringManagedTransaction也是通过工厂类生产的,因此需要重写工厂类如下:

package com.example.demo.datasource;

import org.apache.ibatis.session.TransactionIsolationLevel;
import org.apache.ibatis.transaction.Transaction;
import org.mybatis.spring.transaction.SpringManagedTransactionFactory;

import javax.sql.DataSource;

public class MyTransactionsFactory extends SpringManagedTransactionFactory {
    @Override
    public Transaction newTransaction(DataSource dataSource, TransactionIsolationLevel level, boolean autoCommit) {
        return new MyManagedTransaction(dataSource);
    }
}

最后,SqlSessionFactory类默认使用的是SpringManagedTransactionFactory,别忘了换成我们自己的MyTransactionsFactory,如下:

    @Bean(name = "userSessionFactory")
	public SqlSessionFactory sqlSessionFactory() throws Exception {
		SqlSessionFactoryBean sqlSessionFactoryBean = new SqlSessionFactoryBean();
		sqlSessionFactoryBean.setDataSource(userDataSource());
		sqlSessionFactoryBean.setTransactionFactory(new MyTransactionsFactory());//替换默认的SpringTransactionFactory
    sqlSessionFactoryBean.getObject().getConfiguration().setMapUnderscoreToCamelCase(true);
		return sqlSessionFactoryBean.getObject();
	}

至此,问题解决,便能放心的在事务中使用动态数据源切换了。

四、拓展

      在追查上述问题的时候,看到其他人有遇到同样的问题,也有不同的解决,例如动态数据源无法切换这篇博客所言,在没有使用到JTA的时候也会有同样的问题,这里也坐下解释:

      关于事务管理器,不管是JPA还是JDBC等都实现自接口 PlatformTransactionManager 如果你添加的是 spring-boot-starter-jdbc 依赖,框架会默认注入 DataSourceTransactionManager 实例。如果你添加的是 spring-boot-starter-data-jpa 依赖,框架会默认注入 JpaTransactionManager 实例。

我们在上文中用到的了 new JtaTransactionManager(userTransaction(), transactionManager());实现了PlatformTransactionManager接口,其中userTransaction()和ransactionManager()都由Atomikos帮我们实现,如果使用JDBC,比如在上述博客会有同样的问题,这里粘贴下DataSourceTransactionManager的部分源码留给读者思考

	@Override
	protected void doBegin(Object transaction, TransactionDefinition definition) {
		DataSourceTransactionObject txObject = (DataSourceTransactionObject) transaction;
		Connection con = null;

		try {
                  //提示:这里会出现动态数据源无法切换的问题
			if (!txObject.hasConnectionHolder() ||
					txObject.getConnectionHolder().isSynchronizedWithTransaction()) {
				Connection newCon = this.dataSource.getConnection();
				if (logger.isDebugEnabled()) {
					logger.debug("Acquired Connection [" + newCon + "] for JDBC transaction");
				}
				txObject.setConnectionHolder(new ConnectionHolder(newCon), true);
			}

			txObject.getConnectionHolder().setSynchronizedWithTransaction(true);
			con = txObject.getConnectionHolder().getConnection();

			Integer previousIsolationLevel = DataSourceUtils.prepareConnectionForTransaction(con, definition);
			txObject.setPreviousIsolationLevel(previousIsolationLevel);

			// Switch to manual commit if necessary. This is very expensive in some JDBC drivers,
			// so we don't want to do it unnecessarily (for example if we've explicitly
			// configured the connection pool to set it already).
			if (con.getAutoCommit()) {
				txObject.setMustRestoreAutoCommit(true);
				if (logger.isDebugEnabled()) {
					logger.debug("Switching JDBC Connection [" + con + "] to manual commit");
				}
				con.setAutoCommit(false);
			}

			prepareTransactionalConnection(con, definition);
			txObject.getConnectionHolder().setTransactionActive(true);

			int timeout = determineTimeout(definition);
			if (timeout != TransactionDefinition.TIMEOUT_DEFAULT) {
				txObject.getConnectionHolder().setTimeoutInSeconds(timeout);
			}

			// Bind the connection holder to the thread.
			if (txObject.isNewConnectionHolder()) {
				TransactionSynchronizationManager.bindResource(getDataSource(), txObject.getConnectionHolder());
			}
		}

		catch (Throwable ex) {
			if (txObject.isNewConnectionHolder()) {
				DataSourceUtils.releaseConnection(con, this.dataSource);
				txObject.setConnectionHolder(null, false);
			}
			throw new CannotCreateTransactionException("Could not open JDBC Connection for transaction", ex);
		}
	}

以上是笔者的一些经验分享,不足和错误之处还望指正!

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
Spring Boot 是一个用于快速构建 Java 应用程序的框架。它可以与多种其他框架和组件进行整合,以实现更丰富的功能。在这里,我们将讨论如何使用 Spring Boot 整合 Druid、MyBatis、JTA 分布式事务以及多数据源,同时使用 AOP 注解实现动态切换。 首先,我们可以在 Spring Boot 中集成 Druid 数据源。Druid 是一个高性能的 JDBC 连接池,可以提供监控和统计功能。我们可以通过在 pom.xml 文件中添加相关的依赖,并在 application.properties 文件中配置数据源信息,来实现 Druid 的集成。 接下来,我们可以整合 MyBatis 框架,它是一种优秀的持久化解决方案。我们可以使用 MyBatis 来操作数据库,并将其与 Druid 数据源进行整合。为此,我们需要在 pom.xml 文件中添加 MyBatis 和 MyBatis-Spring 的依赖,并配置 MyBatis 的相关配置文件。 此外,我们还可以使用 JTA(Java Transaction API)实现分布式事务。JTA 可以在分布式环境中协调多个参与者的事务操作。我们可以在 pom.xml 文件中添加 JTA 的依赖,并在 Spring Boot 的配置文件中配置 JTA 的相关属性,以实现分布式事务的支持。 在实现多数据源时,我们可以使用 Spring Boot 的 AbstractRoutingDataSource 来实现动态切换数据源。这个类可以根据当前线程或其他条件选择不同的数据源来进行数据操作。我们可以通过继承 AbstractRoutingDataSource 并实现 determineCurrentLookupKey() 方法来指定当前数据源的 key。然后,在配置文件中配置多个数据源,并将数据源注入到 AbstractRoutingDataSource 中,从而实现动态切换。 最后,我们可以使用 AOP(Aspect Oriented Programming)注解来实现动态切换。AOP 是一种编程范式,可以通过在代码中插入特定的切面(Aspect)来实现横切关注点的处理。我们可以在代码中使用注解来标记需要切换数据源的方法,然后使用 AOP 技术来拦截这些方法,并根据注解中指定的数据源信息来进行数据源切换。 综上所述,通过整合 Druid、MyBatis、JTA 分布式事务以及多数据源,并使用 AOP 注解实现动态切换,我们可以在 Spring Boot 中实现强大而灵活的应用程序。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值