数据库读写分离-spring事务配置篇(转)

如何配置mysql数据库的主从?

单机配置MySQL主从:http://my.oschina.net/god/blog/496

 

常见的解决数据库读写分离有两种方案

1、应用层

http://neoremind.net/2011/06/spring实现数据库读写分离

目前的一些解决方案需要在程序中手动指定数据源,比较麻烦,后边我会通过AOP思想来解决这个问题。

 

2、中间件

mysql-proxy:http://hi.baidu.com/geshuai2008/item/0ded5389c685645f850fab07

Amoeba for MySQL:http://www.iteye.com/topic/188598http://www.iteye.com/topic/1113437

 

此处我们介绍一种在应用层的解决方案,通过spring动态数据源和AOP来解决数据库的读写分离。

 

该方案目前已经在一个互联网项目中使用了,而且可以很好的工作。

 

该方案目前支持

一读多写;当写时默认读操作到写库、当写时强制读操作到读库。

 

考虑未来支持

读库负载均衡、读库故障转移等。

 

使用场景

不想引入中间件,想在应用层解决读写分离,可以考虑这个方案;

建议数据访问层使用jdbc、ibatis,不建议hibernate

 

优势

应用层解决,不引入额外中间件;

在应用层支持『当写时默认读操作到写库』,这样如果我们采用这种方案,在写操作后读数据直接从写库拿,不会产生数据复制的延迟问题;

应用层解决读写分离,理论支持任意数据库。

 

 

缺点

1、不支持@Transactional注解事务,此方案要求所有读方法必须是read-only=true,因此如果是@Transactional,这样就要求在每一个读方法头上加@Transactional 且readOnly属性=true,相当麻烦。 :oops: 

2、必须按照配置约定进行配置,不够灵活。

 

两种方案



方案1:当只有读操作的时候,直接操作读库(从库);

        当在写事务(即写主库)中读时,也是读主库(即参与到主库操作),这样的优势是可以防止写完后可能读不到刚才写的数据;

 

此方案其实是使用事务传播行为为:SUPPORTS解决的。

 


方案2:当只有读操作的时候,直接操作读库(从库);

        当在写事务(即写主库)中读时,强制走从库,即先暂停写事务,开启读(读从库),然后恢复写事务。

此方案其实是使用事务传播行为为:NOT_SUPPORTS解决的。

 

核心组件

cn.javass.common.datasource.ReadWriteDataSource:读写分离的动态数据源,类似于AbstractRoutingDataSource,具体参考javadoc;

cn.javass.common.datasource.ReadWriteDataSourceDecision:读写库选择的决策者,具体参考javadoc;

cn.javass.common.datasource.ReadWriteDataSourceProcessor:此类实现了两个职责(为了减少类的数量将两个功能合并到一起了):读/写动态数据库选择处理器、通过AOP切面实现读/写选择,具体参考javadoc。

 

具体配置

1、数据源配置

1.1、写库配置

 

1.2、读库配置

[html]  view plain  copy
  1. <bean id="readDataSource1" class="org.logicalcobwebs.proxool.ProxoolDataSource">  
  2.     <property name="alias" value="readDataSource"/>  
  3.     <property name="driver" value="${read.connection.driver_class}" />  
  4.     <property name="driverUrl" value="${read.connection.url}" />  
  5.     <property name="user" value="${read.connection.username}" />  
  6.     <property name="password" value="${read.connection.password}" />  
  7.     <property name="maximumConnectionCount" value="${read.proxool.maximum.connection.count}"/>  
  8.     <property name="minimumConnectionCount" value="${read.proxool.minimum.connection.count}" />  
  9.     <property name="statistics" value="${read.proxool.statistics}" />  
  10.     <property name="simultaneousBuildThrottle" value="${read.proxool.simultaneous.build.throttle}"/>  
  11. </bean>   

1.3、读写动态库配置   

通过writeDataSource指定写库,通过readDataSourceMap指定从库列表,从库列表默认通过顺序轮询来使用读库,具体参考javadoc;

[html]  view plain  copy
  1. <bean id="readWriteDataSource" class="cn.javass.common.datasource.ReadWriteDataSource">  
  2.     <property name="writeDataSource" ref="writeDataSource"/>  
  3.     <property name="readDataSourceMap">  
  4.        <map>  
  5.           <entry key="readDataSource1" value-ref="readDataSource1"/>  
  6.           <entry key="readDataSource2" value-ref="readDataSource1"/>  
  7.           <entry key="readDataSource3" value-ref="readDataSource1"/>  
  8.           <entry key="readDataSource4" value-ref="readDataSource1"/>  
  9.        </map>  
  10.     </property>  
  11. </bean>   

2、XML事务属性配置

所以读方法必须是read-only(必须,以此来判断是否是读方法)。

[html]  view plain  copy
  1. <tx:advice id="txAdvice" transaction-manager="txManager">  
  2.     <tx:attributes>  
  3.         <tx:method name="save*" propagation="REQUIRED" />  
  4.         <tx:method name="add*" propagation="REQUIRED" />  
  5.         <tx:method name="create*" propagation="REQUIRED" />  
  6.         <tx:method name="insert*" propagation="REQUIRED" />  
  7.         <tx:method name="update*" propagation="REQUIRED" />  
  8.         <tx:method name="merge*" propagation="REQUIRED" />  
  9.         <tx:method name="del*" propagation="REQUIRED" />  
  10.         <tx:method name="remove*" propagation="REQUIRED" />  
  11.           
  12.         <tx:method name="put*" read-only="true"/>  
  13.         <tx:method name="query*" read-only="true"/>  
  14.         <tx:method name="use*" read-only="true"/>  
  15.         <tx:method name="get*" read-only="true" />  
  16.         <tx:method name="count*" read-only="true" />  
  17.         <tx:method name="find*" read-only="true" />  
  18.         <tx:method name="list*" read-only="true" />  
  19.           
  20.         <tx:method name="*" propagation="REQUIRED"/>  
  21.     </tx:attributes>  
  22. </tx:advice>   

3、事务管理器

事务管理器管理的是readWriteDataSource

[html]  view plain  copy
  1. <bean id="txManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">  
  2.         <property name="dataSource" ref="readWriteDataSource"/>  
  3.     </bean>   

4、读/写动态数据库选择处理器

根据之前的txAdvice配置的事务属性决定是读/写,具体参考javadoc;

forceChoiceReadWhenWrite:用于确定在如果目前是写(即开启了事务),下一步如果是读,是直接参与到写库进行读,还是强制从读库读,具体参考javadoc;

[html]  view plain  copy
  1. <bean id="readWriteDataSourceTransactionProcessor" class="cn.javass.common.datasource.ReadWriteDataSourceProcessor">  
  2.     <property name="forceChoiceReadWhenWrite" value="false"/>  
  3.  </bean>   


5、事务切面和读/写库选择切面

[html]  view plain  copy
  1. <aop:config expose-proxy="true">  
  2.     <!-- 只对业务逻辑层实施事务 -->  
  3.     <aop:pointcut id="txPointcut" expression="execution(* cn.javass..service..*.*(..))" />  
  4.     <aop:advisor advice-ref="txAdvice" pointcut-ref="txPointcut"/>  
  5.       
  6.     <!-- 通过AOP切面实现读/写库选择 -->  
  7.     <aop:aspect order="-2147483648" ref="readWriteDataSourceTransactionProcessor">  
  8.        <aop:around pointcut-ref="txPointcut" method="determineReadOrWriteDB"/>  
  9.     </aop:aspect>  
  10. </aop:config>  

1、事务切面一般横切业务逻辑层;

2、此处我们使用readWriteDataSourceTransactionProcessor的通过AOP切面实现读/写库选择功能,order=Integer.MIN_VALUE(即最高的优先级),从而保证在操作事务之前已经决定了使用读/写库。

 

6、测试用例

只要配置好事务属性(通过read-only=true指定读方法)即可,其他选择读/写库的操作都交给readWriteDataSourceTransactionProcessor完成。

 

可以参考附件的:

cn.javass.readwrite.ReadWriteDBTestWithForceChoiceReadOnWriteFalse

cn.javass.readwrite.ReadWriteDBTestWithNoForceChoiceReadOnWriteTrue


[java]  view plain  copy
  1. package cn.javass.common.datasource;  
  2.   
  3. import java.sql.Connection;  
  4. import java.sql.SQLException;  
  5. import java.util.Map;  
  6. import java.util.Map.Entry;  
  7. import java.util.concurrent.atomic.AtomicInteger;  
  8.   
  9. import javax.sql.DataSource;  
  10.   
  11. import org.slf4j.Logger;  
  12. import org.slf4j.LoggerFactory;  
  13. import org.springframework.beans.factory.InitializingBean;  
  14. import org.springframework.jdbc.datasource.AbstractDataSource;  
  15. import org.springframework.util.CollectionUtils;  
  16.   
  17. /** 
  18.  *  
  19.  * <pre> 
  20.  * 读/写动态选择数据库实现 
  21.  * 目前实现功能 
  22.  *   一写库多读库选择功能,请参考 
  23.  *      @see cn.javass.common.datasource.ReadWriteDataSourceDecision 
  24.         @see cn.javass.common.datasource.ReadWriteDataSourceDecision.DataSourceType 
  25.  *    
  26.  *   默认按顺序轮询使用读库 
  27.  *   默认选择写库 
  28.  *    
  29.  *   已实现:一写多读、当写时默认读操作到写库、当写时强制读操作到读库 
  30.  *   TODO 读库负载均衡、读库故障转移 
  31.  * </pre>   
  32.  * @author Zhang Kaitao  
  33.  * 
  34.  */  
  35. public class ReadWriteDataSource extends AbstractDataSource implements InitializingBean {  
  36.     private static final Logger log = LoggerFactory.getLogger(ReadWriteDataSource.class);  
  37.       
  38.     private DataSource writeDataSource;  
  39.     private Map<String, DataSource> readDataSourceMap;  
  40.       
  41.       
  42.     private String[] readDataSourceNames;  
  43.     private DataSource[] readDataSources;  
  44.     private int readDataSourceCount;  
  45.   
  46.     private AtomicInteger counter = new AtomicInteger(1);  
  47.   
  48.       
  49.     /** 
  50.      * 设置读库(name, DataSource) 
  51.      * @param readDataSourceMap 
  52.      */  
  53.     public void setReadDataSourceMap(Map<String, DataSource> readDataSourceMap) {  
  54.         this.readDataSourceMap = readDataSourceMap;  
  55.     }  
  56.     public void setWriteDataSource(DataSource writeDataSource) {  
  57.         this.writeDataSource = writeDataSource;  
  58.     }  
  59.       
  60.       
  61.     @Override  
  62.     public void afterPropertiesSet() throws Exception {  
  63.         if(writeDataSource == null) {  
  64.             throw new IllegalArgumentException("property 'writeDataSource' is required");  
  65.         }  
  66.         if(CollectionUtils.isEmpty(readDataSourceMap)) {  
  67.             throw new IllegalArgumentException("property 'readDataSourceMap' is required");  
  68.         }  
  69.         readDataSourceCount = readDataSourceMap.size();  
  70.           
  71.         readDataSources = new DataSource[readDataSourceCount];  
  72.         readDataSourceNames = new String[readDataSourceCount];  
  73.           
  74.         int i = 0;  
  75.         for(Entry<String, DataSource> e : readDataSourceMap.entrySet()) {  
  76.             readDataSources[i] = e.getValue();  
  77.             readDataSourceNames[i] = e.getKey();  
  78.             i++;  
  79.         }  
  80.           
  81.           
  82.     }  
  83.       
  84.       
  85.     private DataSource determineDataSource() {  
  86.         if(ReadWriteDataSourceDecision.isChoiceWrite()) {  
  87.             log.debug("current determine write datasource");  
  88.             return writeDataSource;  
  89.         }  
  90.           
  91.         if(ReadWriteDataSourceDecision.isChoiceNone()) {  
  92.             log.debug("no choice read/write, default determine write datasource");  
  93.             return writeDataSource;  
  94.         }   
  95.         return determineReadDataSource();  
  96.     }  
  97.       
  98.     private DataSource determineReadDataSource() {  
  99.         //按照顺序选择读库   
  100.         //TODO 算法改进   
  101.         int index = counter.incrementAndGet() % readDataSourceCount;  
  102.         if(index < 0) {  
  103.             index = - index;  
  104.         }  
  105.               
  106.         String dataSourceName = readDataSourceNames[index];  
  107.           
  108.         log.debug("current determine read datasource : {}", dataSourceName);  
  109.   
  110.         return readDataSources[index];  
  111.     }  
  112.       
  113.     @Override  
  114.     public Connection getConnection() throws SQLException {  
  115.         return determineDataSource().getConnection();  
  116.     }  
  117.       
  118.     @Override  
  119.     public Connection getConnection(String username, String password) throws SQLException {  
  120.         return determineDataSource().getConnection(username, password);  
  121.     }  
  122.   
  123. }  

[java]  view plain  copy
  1. package cn.javass.common.datasource;  
  2.   
  3.   
  4. /** 
  5.  * <pre> 
  6.  * 读/写动态数据库 决策者 
  7.  * 根据DataSourceType是write/read 来决定是使用读/写数据库 
  8.  * 通过ThreadLocal绑定实现选择功能 
  9.  * </pre> 
  10.  * @author Zhang Kaitao 
  11.  * 
  12.  */  
  13. public class ReadWriteDataSourceDecision {  
  14.       
  15.     public enum DataSourceType {  
  16.         write, read;  
  17.     }  
  18.       
  19.       
  20.     private static final ThreadLocal<DataSourceType> holder = new ThreadLocal<DataSourceType>();  
  21.   
  22.     public static void markWrite() {  
  23.         holder.set(DataSourceType.write);  
  24.     }  
  25.       
  26.     public static void markRead() {  
  27.         holder.set(DataSourceType.read);  
  28.     }  
  29.       
  30.     public static void reset() {  
  31.         holder.set(null);  
  32.     }  
  33.       
  34.     public static boolean isChoiceNone() {  
  35.         return null == holder.get();   
  36.     }  
  37.       
  38.     public static boolean isChoiceWrite() {  
  39.         return DataSourceType.write == holder.get();  
  40.     }  
  41.       
  42.     public static boolean isChoiceRead() {  
  43.         return DataSourceType.read == holder.get();  
  44.     }  
  45.   
  46. }  


[java]  view plain  copy
  1. package cn.javass.common.datasource;  
  2.   
  3. import java.lang.reflect.Field;  
  4. import java.util.HashMap;  
  5. import java.util.Map;  
  6. import java.util.Map.Entry;  
  7.   
  8. import org.aspectj.lang.ProceedingJoinPoint;  
  9. import org.aspectj.lang.annotation.Around;  
  10. import org.slf4j.Logger;  
  11. import org.slf4j.LoggerFactory;  
  12. import org.springframework.beans.BeansException;  
  13. import org.springframework.beans.factory.config.BeanPostProcessor;  
  14. import org.springframework.core.NestedRuntimeException;  
  15. import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;  
  16. import org.springframework.transaction.annotation.Propagation;  
  17. import org.springframework.transaction.interceptor.NameMatchTransactionAttributeSource;  
  18. import org.springframework.transaction.interceptor.RuleBasedTransactionAttribute;  
  19. import org.springframework.transaction.interceptor.TransactionAttribute;  
  20. import org.springframework.transaction.interceptor.TransactionInterceptor;  
  21. import org.springframework.util.PatternMatchUtils;  
  22. import org.springframework.util.ReflectionUtils;  
  23.   
  24. /** 
  25.  *  
  26.  *  
  27.  * <pre> 
  28.  *  
  29.  * 此类实现了两个职责(为了减少类的数量将两个功能合并到一起了): 
  30.  *   读/写动态数据库选择处理器 
  31.  *   通过AOP切面实现读/写选择 
  32.  *    
  33.  *    
  34.  * ★★读/写动态数据库选择处理器★★ 
  35.  * 1、首先读取<tx:advice>事务属性配置 
  36.  *  
  37.  * 2、对于所有读方法设置 read-only="true" 表示读取操作(以此来判断是选择读还是写库),其他操作都是走写库 
  38.  *    如<tx:method name="×××" read-only="true"/> 
  39.  *     
  40.  * 3、 forceChoiceReadOnWrite用于确定在如果目前是写(即开启了事务),下一步如果是读, 
  41.  *    是直接参与到写库进行读,还是强制从读库读<br/> 
  42.  *      forceChoiceReadOnWrite:true 表示目前是写,下一步如果是读,强制参与到写事务(即从写库读) 
  43.  *                                  这样可以避免写的时候从读库读不到数据 
  44.  *                                   
  45.  *                                  通过设置事务传播行为:SUPPORTS实现 
  46.  *                                   
  47.  *      forceChoiceReadOnWrite:false 表示不管当前事务是写/读,都强制从读库获取数据 
  48.  *                                  通过设置事务传播行为:NOT_SUPPORTS实现(连接是尽快释放)                 
  49.  *                                  『此处借助了 NOT_SUPPORTS会挂起之前的事务进行操作 然后再恢复之前事务完成的』 
  50.  * 4、配置方式 
  51.  *  <bean id="readWriteDataSourceTransactionProcessor" class="cn.javass.common.datasource.ReadWriteDataSourceProcessor"> 
  52.  *      <property name="forceChoiceReadWhenWrite" value="false"/> 
  53.  *  </bean> 
  54.  * 
  55.  * 5、目前只适用于<tx:advice>情况 TODO 支持@Transactional注解事务 
  56.  *   
  57.  *   
  58.  *   
  59.  * ★★通过AOP切面实现读/写库选择★★ 
  60.  *  
  61.  * 1、首先将当前方法 与 根据之前【读/写动态数据库选择处理器】  提取的读库方法 进行匹配 
  62.  *  
  63.  * 2、如果匹配,说明是读取数据: 
  64.  *  2.1、如果forceChoiceReadOnWrite:true,即强制走读库 
  65.  *  2.2、如果之前是写操作且forceChoiceReadOnWrite:false,将从写库进行读取 
  66.  *  2.3、否则,到读库进行读取数据 
  67.  *  
  68.  * 3、如果不匹配,说明默认将使用写库进行操作 
  69.  *  
  70.  * 4、配置方式 
  71.  *      <aop:aspect order="-2147483648" ref="readWriteDataSourceTransactionProcessor"> 
  72.  *          <aop:around pointcut-ref="txPointcut" method="determineReadOrWriteDB"/> 
  73.  *      </aop:aspect> 
  74.  *  4.1、此处order = Integer.MIN_VALUE 即最高的优先级(请参考http://jinnianshilongnian.iteye.com/blog/1423489) 
  75.  *  4.2、切入点:txPointcut 和 实施事务的切入点一样 
  76.  *  4.3、determineReadOrWriteDB方法用于决策是走读/写库的,请参考 
  77.  *       @see cn.javass.common.datasource.ReadWriteDataSourceDecision 
  78.  *       @see cn.javass.common.datasource.ReadWriteDataSource 
  79.  *  
  80.  * </pre> 
  81.  * @author Zhang Kaitao 
  82.  * 
  83.  */  
  84. public class ReadWriteDataSourceProcessor implements BeanPostProcessor {  
  85.     private static final Logger log = LoggerFactory.getLogger(ReadWriteDataSourceProcessor.class);  
  86.       
  87.     private boolean forceChoiceReadWhenWrite = false;  
  88.       
  89.     private Map<String, Boolean> readMethodMap = new HashMap<String, Boolean>();  
  90.   
  91.     /** 
  92.      * 当之前操作是写的时候,是否强制从从库读 
  93.      * 默认(false) 当之前操作是写,默认强制从写库读 
  94.      * @param forceReadOnWrite 
  95.      */  
  96.       
  97.     public void setForceChoiceReadWhenWrite(boolean forceChoiceReadWhenWrite) {  
  98.           
  99.         this.forceChoiceReadWhenWrite = forceChoiceReadWhenWrite;  
  100.     }  
  101.       
  102.   
  103.     @Override  
  104.     public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {  
  105.   
  106.         if(!(bean instanceof NameMatchTransactionAttributeSource)) {  
  107.             return bean;  
  108.         }  
  109.           
  110.         try {  
  111.             NameMatchTransactionAttributeSource transactionAttributeSource = (NameMatchTransactionAttributeSource)bean;  
  112.             Field nameMapField = ReflectionUtils.findField(NameMatchTransactionAttributeSource.class"nameMap");  
  113.             nameMapField.setAccessible(true);  
  114.             Map<String, TransactionAttribute> nameMap = (Map<String, TransactionAttribute>) nameMapField.get(transactionAttributeSource);  
  115.               
  116.             for(Entry<String, TransactionAttribute> entry : nameMap.entrySet()) {  
  117.                 RuleBasedTransactionAttribute attr = (RuleBasedTransactionAttribute)entry.getValue();  
  118.   
  119.                 //仅对read-only的处理  
  120.                 if(!attr.isReadOnly()) {  
  121.                     continue;  
  122.                 }  
  123.                   
  124.                 String methodName = entry.getKey();  
  125.                 Boolean isForceChoiceRead = Boolean.FALSE;  
  126.                 if(forceChoiceReadWhenWrite) {  
  127.                     //不管之前操作是写,默认强制从读库读 (设置为NOT_SUPPORTED即可)  
  128.                     //NOT_SUPPORTED会挂起之前的事务  
  129.                     attr.setPropagationBehavior(Propagation.NOT_SUPPORTED.value());  
  130.                     isForceChoiceRead = Boolean.TRUE;  
  131.                 } else {  
  132.                     //否则 设置为SUPPORTS(这样可以参与到写事务)  
  133.                     attr.setPropagationBehavior(Propagation.SUPPORTS.value());  
  134.                 }  
  135.                 log.debug("read/write transaction process  method:{} force read:{}", methodName, isForceChoiceRead);  
  136.                 readMethodMap.put(methodName, isForceChoiceRead);  
  137.             }  
  138.               
  139.         } catch (Exception e) {  
  140.             throw new ReadWriteDataSourceTransactionException("process read/write transaction error", e);  
  141.         }  
  142.           
  143.         return bean;  
  144.     }  
  145.       
  146.       
  147.     @Override  
  148.     public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {  
  149.         return bean;  
  150.     }  
  151.   
  152.     private class ReadWriteDataSourceTransactionException extends NestedRuntimeException {  
  153.         public ReadWriteDataSourceTransactionException(String message, Throwable cause) {  
  154.             super(message, cause);  
  155.         }  
  156.     }  
  157.       
  158.       
  159.       
  160.       
  161.       
  162.     public Object determineReadOrWriteDB(ProceedingJoinPoint pjp) throws Throwable {  
  163.           
  164.         if (isChoiceReadDB(pjp.getSignature().getName())) {  
  165.             ReadWriteDataSourceDecision.markRead();  
  166.         } else {  
  167.             ReadWriteDataSourceDecision.markWrite();  
  168.         }  
  169.               
  170.         try {  
  171.             return pjp.proceed();  
  172.         } finally {  
  173.             ReadWriteDataSourceDecision.reset();  
  174.         }  
  175.           
  176.           
  177.     }  
  178.       
  179.     private boolean isChoiceReadDB(String methodName) {  
  180.   
  181.         String bestNameMatch = null;  
  182.         for (String mappedName : this.readMethodMap.keySet()) {  
  183.             if (isMatch(methodName, mappedName)) {  
  184.                 bestNameMatch = mappedName;  
  185.                 break;  
  186.             }  
  187.         }  
  188.   
  189.         Boolean isForceChoiceRead = readMethodMap.get(bestNameMatch);  
  190.         //表示强制选择 读 库  
  191.         if(isForceChoiceRead == Boolean.TRUE) {  
  192.             return true;  
  193.         }  
  194.           
  195.         //如果之前选择了写库 现在还选择 写库  
  196.         if(ReadWriteDataSourceDecision.isChoiceWrite()) {  
  197.             return false;  
  198.         }  
  199.           
  200.         //表示应该选择读库  
  201.         if(isForceChoiceRead != null) {  
  202.             return true;  
  203.         }  
  204.         //默认选择 写库  
  205.         return false;  
  206.     }  
  207.   
  208.   
  209.     protected boolean isMatch(String methodName, String mappedName) {  
  210.         return PatternMatchUtils.simpleMatch(mappedName, methodName);  
  211.     }  
  212.   
  213. }  

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值