【SpringBoot】多数据源切换

一,多数据源

一个主库和N个应用库的数据源,并且会同时操作主库和应用库的数据,需要解决以下两个问题:

1,如何动态管理多个数据源以及切换?

2,如何保证多数据源场景下的数据一致性(事务)?

本文主要探讨这两个问题的解决方案,希望能对读者有一定的启发。

1,DB的分库分表

2,微服务的架构

3,数据库的同步和迁移

1.1 概述

市面上常见的多数据源实现方案如下:

方案1:基于Spring框架提供的AbstractRoutingDataSource。

优点: 简单易用,支持动态切换数据源;适用于少量数据源情况。
场景:适用于需要动态切换数据源,且数据库较少的情况。
文档地址:


方案2:使用MP提供的Dynamic-datasource多数据源框架。

文档地址:https://baomidou.com/guides/dynamic-datasource/#dynamic-datasource


方案3:通过自定义注解在方法或类上指定数据源,实现根据注解切换数据源的功能。

优点: 灵活性高,能够精确地控制数据源切换;在代码中直观明了。
场景: 适用于需要在代码层面进行数据源切换,并对数据源切换有精细要求的情况。


方案4:使用动态代理技术,在运行时动态切换数据源,实现多数据源的切换。

优点: 灵活性高,支持在运行时动态切换数据源;适合对数据源切换的逻辑有特殊需求的情况。
场景: 适用于需要在运行时动态决定数据源切换策略的情况。

最原始的使用案例

@Configuration
public class DataSourceConfig {
 
    @Bean
    @Primary
    public DataSource dataSource() {
        // 配置主数据源
        DruidDataSource dataSource = new DruidDataSource();
        dataSource.setUrl("jdbc:mysql://localhost:3306/primary_db");
        dataSource.setUsername("primary_user");
        dataSource.setPassword("primary_password");
        // 其他配置...
        return dataSource;
    }
 
    @Bean
    public DataSource secondaryDataSource() {
        // 配置第二数据源
        DruidDataSource dataSource = new DruidDataSource();
        dataSource.setUrl("jdbc:mysql://localhost:3306/secondary_db");
        dataSource.setUsername("secondary_user");
        dataSource.setPassword("secondary_password");
        // 其他配置...
        return dataSource;
    }
 
    // 如果有更多数据源,继续添加更多的@Bean方法来配置
}

1.2 AbstractRoutingDataSource

本质用的是多态和本地线程栈,适用于老项目动态切换数据源业务,单体服务。 

1.2.1 原理 

Spring boot提供了AbstractRoutingDataSource 根据用户定义的规则选择当前的数据源,这样我们可以在执行数据库操作之前,设置使用的数据源,即可实现数据源的动态路由。它的抽象方法determineCurrentLookupKey() 决定使用哪个数据源。

AbstractRoutingDataSource类实现了InitializingBean接口,所以此方法在这个bean初始化时执行afterPropertiesSet方法。afterPropertiesSet方法将配置文件中注入的targetDataSources和defaultTargetDataSource 转换为了后续可以使用的resolvedDefaultDataSource(默认数据源)和resolvedDataSources(存储使用的名称-数据源映射)

sql执行会调用DynamicDataSource父类getConnection方法,执行调用子类重写的determineTargetDataSource决定数据源key,子类会从本地线程栈中获业务指定数据源key(公司号)

 1.2.2 yml文件配置

spring:
  application:
    name: test
  datasource:
    druid:
      type: com.alibaba.druid.pool.DruidDataSource
      driverClassName: com.mysql.jdbc.Driver
      first:
        url: jdbc:mysql://xxxx
        username: root
        password: root
      second:
        url: jdbc:mysql://xxx
        username: root
        password: root
mybatis:
  mapper-locations: classpath:mapper/*.xml

1.2.3 DynamicDataSource

 DynamicDataSource继承AbstractRoutingDataSource,重写determineCurrentLookupKey方法。

/**动态数据源
 * 扩展 Spring 的 AbstractRoutingDataSource 抽象类,重写 determineCurrentLookupKey 方法
 * determineCurrentLookupKey() 方法决定使用哪个数据源
 *
 */
public class DynamicDataSource extends AbstractRoutingDataSource {
 
    /**
     * ThreadLocal 用于提供线程局部变量,在多线程环境可以保证各个线程里的变量独立于其它线程里的变量。
     * 也就是说 ThreadLocal 可以为每个线程创建一个【单独的变量副本】,相当于线程的 private static 类型变量。
     */
    private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();
 
    /**
     * 决定使用哪个数据源之前需要把多个数据源的信息以及默认数据源信息配置好
     *
     * @param defaultTargetDataSource 默认数据源
     * @param targetDataSources       目标数据源
     */
    public DynamicDataSource(DataSource defaultTargetDataSource, Map<Object, Object> targetDataSources) {
        super.setDefaultTargetDataSource(defaultTargetDataSource);
        super.setTargetDataSources(targetDataSources);
        super.afterPropertiesSet();
    }
 
    @Override
    protected Object determineCurrentLookupKey() {
        return getDataSource();
    }
 
    public static void setDataSource(String dataSource) {
        CONTEXT_HOLDER.set(dataSource);
    }
 
    public static String getDataSource() {
        return CONTEXT_HOLDER.get();
    }
 
    public static void clearDataSource() {
        CONTEXT_HOLDER.remove();
    }
 
}

 1.2.4 将动态数据源注入到Spring容器中

/**
 * 配置多数据源
 */
@Configuration
public class DynamicDataSourceConfig {
 
    private static final String FIRST = "first";
    private static final String SECOND = "second";
 
    @Bean
    @ConfigurationProperties("spring.datasource.druid.first")
    public DataSource firstDataSource(){
        return DruidDataSourceBuilder.create().build();
    }
 
    @Bean
    @ConfigurationProperties("spring.datasource.druid.second")
    public DataSource secondDataSource(){
        return DruidDataSourceBuilder.create().build();
    }
 
    @Bean
    @Primary
    public DynamicDataSource dataSource(DataSource firstDataSource, DataSource secondDataSource) {
        Map<Object, Object> targetDataSources = new HashMap<>(5);
        targetDataSources.put(FIRST, firstDataSource);
        targetDataSources.put(SECOND, secondDataSource);
        return new DynamicDataSource(firstDataSource, targetDataSources);
    }

    //通过JdbcTemplate
    //也可以不需要
    @Bean
    public JdbcTemplate jdbcTemplate(@Qualifier("dataSource") DataSource ds) {
        return new JdbcTemplate(ds);
    }
}

1.2.5 注解 + aop

自定义注解

/**
 * 多数据源注解
 * 指定要使用的数据源
 *
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CurDataSource {
    String name() default "";
}

多数据源AOP切面

/**
 * 多数据源,切面处理类
 *
 * @author xiaohe
 * @version V1.0.0
 */
@Slf4j
@Aspect
@Component
public class DataSourceAspect implements Ordered {
 
    private static final String FIRST = "first";
 
    private static final String SECOND = "second";
 
    @Pointcut("@annotation(com.hc.datasource.CurDataSource)")
    public void dataSourcePointCut() {
 
    }
 
    @Around("dataSourcePointCut()")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
 
        CurDataSource ds = method.getAnnotation(CurDataSource.class);
        if (ds == null) {
            DynamicDataSource.setDataSource(FIRST);
            log.debug("set datasource is " + FIRST);
        } else {
            DynamicDataSource.setDataSource(ds.name());
            log.debug("set datasource is " + ds.name());
        }
 
        try {
            return point.proceed();
        } finally {
            DynamicDataSource.clearDataSource();
            log.debug("clean datasource");
        }
    }
 
    @Override
    public int getOrder() {
        return 1;
    }
}

数据源的实例化做的不太好,本次就是有几个数据源,就手动实例化几个数据源。如果数据源很多的话,一个个构造的话很麻烦。

启动类中配置移除默认的数据库配置类

@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class}) 非常重要

1.3. ImportBeanDefinitionRegistrar

1.3.1 概述

ImportBeanDefinitionRegistrar接口是也是spring的扩展点之一,ImportBeanDefinitionRegistrar类只能通过其他类 @Import的方式来加载,通常是启动类或配置类。它可以支持我们自己写的代码封装成BeanDefinition对象;实现此接口的类会回调postProcessBeanDefinitionRegistry方法,注册到spring容器中。

加载指定类

定义一个类TestImportBeanDefinitionRegistrar实现ImportBeanDefinitionRegistrar接口,在配置类TestConfiguration加上注解@Import一个TestImportBeanDefinitionRegistrar实现类,重写registerBeanDefinitions方法,手动注册bean,实现注册BeanDefinition到容器中,也可以实现一些Aware接口,以便获取Spring的一些数据。

1.3.2 原理

ImportBeanDefinitionRegistrar实现多数据源注入,关键节点在于自定义新的DynamicDataSource的bean文件,其本质通过EnvironmentAware获得环境配置,然后通过配置实现。

配置文件 application.yml,指定names: first,second多种数据源,通过拆分names实现手动创建数据源。

1.3.3 application.yml

spring:
  application:
    name: test
  datasource:
    driverClassName: com.mysql.jdbc.Driver
    type: com.alibaba.druid.pool.DruidDataSource
    url: xxxxx  #默认数据源
    username: root
    password: root
    names: first,second
    first:  #其他数据源
      driverClassName: com.mysql.jdbc.Driver
      type: com.alibaba.druid.pool.DruidDataSource
      url:xxxxx
      username: root
      password: root
    second: #其他数据源
      driverClassName: com.mysql.jdbc.Driver
      type: com.alibaba.druid.pool.DruidDataSource
      url: jdbc:mysql://121.40.182.123:3306/gulimall_oms?useUnicode=true&characterEncoding=UTF-8
      username: root
      password: root
mybatis:
  mapper-locations: classpath:mapper/*.xml

1.3.4 配置文件注入数据源

/**
 * @description: 注册动态数据源
 */
@Slf4j
public class DynamicDataSourceRegister implements ImportBeanDefinitionRegistrar, EnvironmentAware {
    //默认数据源(主数据源)
    private DataSource defaultDataSource;
    //其他数据源
    private Map<String, DataSource> customDataSources = new HashMap<>();

    /*凡注册到Spring容器内的bean,
    实现了EnvironmentAware接口重写setEnvironment方法后,
    在工程启动时可以获得application.properties的配置文件配置的属性值     
    environment 接口主要在beanfactory就实现了环境配置
   */
    @Override
    public void setEnvironment(Environment environment) {
        initDefaultDataSource(environment);
        initCustomDataSources(environment);
    }

    /**
     * 向Spring容器中注入动态数据源
     * 实现ImportBeanDefinitionRegistrar接口,重新此类注册beanDefinition
     * 但是实际上可以不用,在config阶段自己手动注册更为简单明了
     *  @param importingClassMetadata
     *  @param registry
     */
    @Override
    public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
        Map<Object, Object> targetDataSources = new HashMap<Object, Object>();
        // 将主数据源添加到更多数据源中
        targetDataSources.put("dataSource", defaultDataSource);
        DynamicDataSourceContextHolder.dataSourceIds.add("dataSource");
        // 添加更多数据源
        targetDataSources.putAll(customDataSources);
        for (String key : customDataSources.keySet()) {
            DynamicDataSourceContextHolder.dataSourceIds.add(key);
        }
        // 创建DynamicDataSource
        GenericBeanDefinition beanDefinition = new GenericBeanDefinition();
        beanDefinition.setBeanClass(DynamicDataSource.class);
        beanDefinition.setSynthetic(true);
        MutablePropertyValues mpv = beanDefinition.getPropertyValues();
        mpv.addPropertyValue("defaultTargetDataSource", defaultDataSource);
        mpv.addPropertyValue("targetDataSources", targetDataSources);
        registry.registerBeanDefinition("dataSource", beanDefinition);
    }

    private void initDefaultDataSource(Environment env) {
        // 读取主数据源配置
        Map<String, Object> dsMap = new HashMap<>();
        dsMap.put("type", env.getProperty("spring.datasource.type"));
        dsMap.put("driver-class-name", env.getProperty("spring.datasource.driver-class-name"));
        dsMap.put("url", env.getProperty("spring.datasource.url"));
        dsMap.put("username", env.getProperty("spring.datasource.username"));
        dsMap.put("password", env.getProperty("spring.datasource.password"));
        defaultDataSource = buildDataSource(dsMap);
        dataBinder(defaultDataSource, env);
    }

    /**
     * 利用读取的配置创建数据源 *
     *
     * @param dsMap
     * @return
     */
    public DataSource buildDataSource(Map<String, Object> dsMap) {
        Object type = dsMap.get("type");
        Class<? extends DataSource> dataSourceType;
        try {
            dataSourceType = (Class<? extends DataSource>) Class.forName((String) type);
            String driverClassName = dsMap.get("driver-class-name").toString();
            String url = dsMap.get("url").toString();
            String username = dsMap.get("username").toString();
            String password = dsMap.get("password").toString();
            DataSource defaultDataSource = DataSourceBuilder.create().type(dataSourceType).driverClassName(driverClassName).url(url).username(username).password(password).build();
            return defaultDataSource;
        } catch (ClassNotFoundException e) {
            log.error("buildDataSource from config error!", e);
        }
        return null;
    }

    /**
     * 为数据源绑定更多属性
     *
     * @param dataSource *
     * @param env        todo
     */
    private void dataBinder(DataSource dataSource, Environment env) {

    }

    /**
     * 初始化更多数据源
     */
    private void initCustomDataSources(Environment env) {
        // 读取配置文件获取更多数据源,也可以通过defaultDataSource读取数据库获取更多数据源
        String dataSourceNames = env.getProperty("spring.datasource.names");
        if (StringUtils.isNotBlank(dataSourceNames)) {
            for (String dsPrefix : dataSourceNames.split(",")) {// 多个数据源
                Iterable<ConfigurationPropertySource> sources = ConfigurationPropertySources.get(env);
                Binder binder = new Binder(sources);
                BindResult<Properties> bindResult = binder.bind("spring.datasource." + dsPrefix, Properties.class);
                Properties properties = bindResult.get();
                Map<String, Object> dsMap = new HashMap<>();
                dsMap.put("type", properties.getProperty("type"));
                dsMap.put("driver-class-name", properties.getProperty("driverClassName"));
                dsMap.put("url", properties.getProperty("url"));
                dsMap.put("username", properties.getProperty("username"));
                dsMap.put("password", properties.getProperty("password"));
                DataSource ds = buildDataSource(dsMap);
                dataBinder(ds, env);
                customDataSources.put(dsPrefix, ds);
            }
        }
    }
}

1.3.5  DynamicDataSourceContextHolder  

 DynamicDataSourceContextHolder 类,决定当前数据库操作使用的数据源

public class DynamicDataSourceContextHolder {
    private static final ThreadLocal<String> contextHolder = new ThreadLocal<String>();
    public static List<String> dataSourceIds = new ArrayList<>();

    public static void setDataSourceType(String dataSourceType) {
        contextHolder.set(dataSourceType);
    }

    public static String getDataSourceType() {
        return contextHolder.get();
    }

    public static void clearDataSourceType() {
        contextHolder.remove();
    }

    /**
     * 判断指定DataSrouce当前是否存在     *
     */
    public static boolean containsDataSource(String dataSourceId) {
        return dataSourceIds.contains(dataSourceId);
    }
}

1.3.6 动态数据源类 DynamicDataSource

/**
 * 动态数据库 *  重写determineCurrentLookupKey方法,决定当前使用的数据源是哪一个
 */
public class DynamicDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return DynamicDataSourceContextHolder.getDataSourceType();
    }
}

1.3.7 注解 +切面

加在service方法上,决定当前使用的数据源是哪一个。

/**
 * 在方法上使用,用于指定使用哪个数据源 *
 */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface TargetDataSource {
    String name();
}

@Slf4j
@Aspect
@Component
public class DataSourceAspect implements Ordered {

    @Pointcut("@annotation(com.hc.datasource.TargetDataSource)")
    public void dataSourcePointCut() {

    }

    @Around("dataSourcePointCut()")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();

        TargetDataSource ds = method.getAnnotation(TargetDataSource.class);
        if (ds != null) {
            DynamicDataSourceContextHolder.setDataSourceType(ds.name());
            log.info("set datasource is " + ds.name());
        }

        try {
            return point.proceed();
        } finally {
            DynamicDataSourceContextHolder.clearDataSourceType();
            log.debug("clean datasource");
        }
    }

    @Override
    public int getOrder() {
        return 1;
    }
}

1.4 多数据源事务

 Spring Boot多数据源事务是指在一个Spring Boot应用中,使用多个数据源来访问和操作不同的数据库,并且通过事务来保证这些操作的一致性和完整性。

在传统的Spring应用中,只能配置一个数据源作为默认数据源,如果需要访问多个数据库,则需要手动创建和管理多个数据库连接和事务。而在Spring Boot中,通过集成多个数据源和使用注解配置事务,可以简化多数据源的管理和事务的使用。

在Spring Boot中,可以配置多个数据源,并且给每个数据源配置一个独立的事务管理器。通过使用@Transactional注解声明一个方法或类为事务管理的方法,可以在该方法中访问和操作多个数据源,同时利用Spring Boot提供的事务管理器来管理这些数据源的事务。

事务管理器会根据具体的注解配置来决定事务的作用范围和隔离级别,例如可以配置一个数据源的事务为只读事务,另一个数据源的事务为读写事务。在使用多数据源事务时,需要注意事务的作用范围和隔离级别,以确保数据的一致性和完整性。

多数据源事务实现方案对比:
1,通过改变传播机制,即通过新开事务的方式。

该方案需要对所有使用到事务的业务代码进行重构,费时费力。

2,配置多套 Mapper,使用不同的事务管理器

该方案需要将现有的 Mapper 拆分为多套,从而实现事务控制。该方案维护成本以及后续开发成本高,更适合将读写拆分为不同 Mapper 的项目。

3,XA 二阶段提交

MySQL 支持 XA 二阶段提交,但该方式比较重,会存在一定的性能问题。且该方式使用 tkmybatis 时,tkmybatis 框架的方法失效;mybatis-plus 框架正常。

4,自定义事务管理器

采用该方案。从数据源失效的原因可知,实现多数据源事务需要将多个数据库链接放到同一个事务中,所以自定义事务管理器,实现多个数据库链接的提交和回滚。

1.4.1 SpringBoot多数据源事务优点

AbstractRoutingDataSource 只支持单库事务,切换数据源是在开启事务之前执行。 Spring使用 DataSourceTransactionManager进行事务管理。开启事务,会将数据源缓存到DataSourceTransactionObject对象中,后续的commit和 rollback事务操作实际上是使用的同一个数据源。 

简化配置:Spring Boot提供了简单易用的配置方式,可以快速配置多个数据源,并且可以根据需要灵活切换数据源。

提高性能:通过使用多数据源,可以将不同的业务数据分散到不同的数据源中,从而提高并发性能。

分布式事务管理:Spring Boot的多数据源事务可以支持分布式事务管理,可以在跨多个数据源的操作中保持事务的一致性。

可靠性:多数据源事务可以在数据源故障时提供容错和恢复机制,确保数据的完整性和一致性。

扩展性:通过使用多数据源,可以轻松地扩展应用程序的数据库层,以满足不同的业务需求。

在 Spring Boot 中使用多数据源时,事务管理变得更复杂,因为每个数据源通常需要独立的事务管理器。以下是如何在多数据源环境中管理事务的常见方法和步骤:

1.4.2 配置多个事务管理器

每个数据源通常需要一个独立的 `PlatformTransactionManager`。你可以在 Spring 配置类中定义多个事务管理器 Bean。例如:

@Configuration
public class TransactionManagerConfig {
 
    @Bean(name = "transactionManager1")
    public PlatformTransactionManager transactionManager1(@Qualifier("dataSource1") DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
 
    @Bean(name = "transactionManager2")
    public PlatformTransactionManager transactionManager2(@Qualifier("dataSource2") DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

1.4.3 配置事务管理器的注解

在使用 `@Transactional` 注解时,你可以指定事务管理器。默认情况下,Spring 会使用主数据源的事务管理器,但你可以通过 `@Transactional` 注解的 `transactionManager` 属性来指定不同的数据源。

每个数据源通常需要一个独立的 `PlatformTransactionManager`。你可以在 Spring 配置类中定义多个事务管理器 Bean。

例如:

 
@Service
public class MyService {
 
    @Transactional(transactionManager = "transactionManager1")
    public void methodForDataSource1() {
        // 使用 dataSource1 的事务
    }
 
    @Transactional(transactionManager = "transactionManager2")
    public void methodForDataSource2() {
        // 使用 dataSource2 的事务
    }
}

 确保你的 `@Transactional` 注解使用正确的数据源和事务管理器。

例如:

 
@Service
public class MyService {
 
    @Transactional(transactionManager = "transactionManager1", propagation = Propagation.REQUIRED)
    public void methodForDataSource1() {
        // 方法内容
    }
    
    @Transactional(transactionManager = "transactionManager2", propagation = Propagation.REQUIRED)
    public void methodForDataSource2() {
        // 方法内容
    }
}

propagation=Propagation.REQUIRES_NEW注解尤为重要,否则进入service2方法无法进入datasource2事务 

1.4.4 使用事务模板

如果需要更细粒度的事务控制,可以使用 `TransactionTemplate`。这种方式允许在代码中显式地控制事务的开始和提交:

 
@Service
public class MyService {
 
    @Autowired
    private TransactionTemplate transactionTemplate1;
 
    @Autowired
    private TransactionTemplate transactionTemplate2;
 
    public void methodForDataSource1() {
        transactionTemplate1.execute(status -> {
            // 使用 dataSource1 的事务
            return null;
        });
    }
    
    public void methodForDataSource2() {
        transactionTemplate2.execute(status -> {
            // 使用 dataSource2 的事务
            return null;
        });
    }
}

1.4.5. 处理分布式事务(可选)

如果你的事务跨多个数据源,并且需要保证全局一致性,考虑使用分布式事务解决方案,如 [Atomikos](https://www.atomikos.com/) 或 [Narayana](https://www.jboss.org/jbosstm/). 这类解决方案通常需要配置协调器和事务管理器。

#### 使用 Atomikos 示例:

1. **添加依赖:**

  <dependency>
       <groupId>com.atomikos</groupId>
       <artifactId>atomikos-jta</artifactId>
       <version>5.0.8</version>
   </dependency>

配置 Atomikos 事务管理器:

   @Configuration
   public class AtomikosTransactionManagerConfig {
 
       @Bean(name = "transactionManager")
       public UserTransactionManager userTransactionManager() throws Throwable {
           UserTransactionManager userTransactionManager = new UserTransactionManager();
           userTransactionManager.init();
           return userTransactionManager;
       }
 
       @Bean(name = "atomikosTransactionManager")
       public JtaTransactionManager transactionManager(UserTransactionManager userTransactionManager) {
           return new JtaTransactionManager(userTransactionManager);
       }
   }

**配置数据源与 Atomikos 结合:**

 
   @Bean(name = "dataSource1")
   public DataSource dataSource1() {
       AtomikosDataSourceBean dataSource = new AtomikosDataSourceBean();
       dataSource.setUniqueResourceName("dataSource1");
       dataSource.setXaDataSourceClassName("com.mysql.cj.jdbc.MysqlXADataSource");
       dataSource.setXaDataSource(new MysqlXADataSource());
       return dataSource;
   }

1.4.6 总结

- 配置每个数据源对应的事务管理器,并在需要的地方通过 `@Transactional` 注解指定使用的事务管理器。
- 使用 `TransactionTemplate` 提供显式的事务控制。
- 对于跨多个数据源的分布式事务,考虑使用专门的分布式事务管理解决方案。

二场景应用

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

常生果

喜欢我,请支持我

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

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

打赏作者

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

抵扣说明:

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

余额充值