03-分布式事务解决方案之可靠消息最终一致性实战

1、分布式事务解决方案之可靠消息最终一致性

1.1 什么是可靠消息最终一致性事务

可靠消息最终一致性方案是指当事务发起方执行完成本地事务后并发出一条消息,事务参与方(消息消费者)一定能 够接收消息并处理事务成功,此方案强调的是只要消息发给事务参与方最终事务要达到一致。 此方案是利用消息中间件完成。
如下图: 事务发起方(消息生产方)将消息发给消息中间件,事务参与方从消息中间件接收消息,事务发起方和消息中间件 之间,事务参与方(消息消费方)和消息中间件之间都是通过网络通信,由于网络通信的不确定性会导致分布式事务问题。
image.png

1.本地事务与消息发送的原子性问题

本地事务与消息发送的原子性问题即:事务发起方在本地事务执行成功后消息必须发出去,否则就丢弃消息。即实 现本地事务和消息发送的原子性,要么都成功,要么都失败。本地事务与消息发送的原子性问题是实现可靠消息最 终一致性方案的关键问题。 先来尝试下这种操作,先发送消息,再操作数据库:

begin transaction; 
//1.发送MQ 
//2.数据库操作 
commit transation;

这种情况下无法保证数据库操作与发送消息的一致性,因为可能发送消息成功,数据库操作失败。 你立马想到第二种方案,先进行数据库操作,再发送消息:

begin transaction; 
//1.数据库操作 
//2.发送MQ 
commit transation;

这种情况下貌似没有问题,如果发送MQ消息失败,就会抛出异常,导致数据库事务回滚。但如果是超时异常,数据库回滚,但MQ其实已经正常发送了,同样会导致不一致。

2、事务参与方接收消息的可靠性

事务参与方必须能够从消息队列接收到消息,如果接收消息失败可以重复接收消息。

3、消息重复消费的问题

由于网络2的存在,若某一个消费节点超时但是消费成功,此时消息中间件会重复投递此消息,就导致了消息的重 复消费。 要解决消息重复消费的问题就要实现事务参与方的方法幂等性。

1.2 解决方案

1、本地消息表方案

本地消息表这个方案最初是eBay提出的,此方案的核心是通过本地事务保证数据业务操作和消息的一致性,然后 通过定时任务将消息发送至消息中间件,待确认消息发送给消费方成功再将消息删除。
下面以注册送积分为例来说明:
下例共有两个微服务交互,用户服务和积分服务,用户服务负责添加用户,积分服务负责增加积分。
image.png

交互流程如下:

1)用户注册

用户服务在本地事务新增用户和增加 ”积分消息日志“。
(用户表和消息表通过本地事务保证一致) 下边是伪代码

begin transaction; 
//1.新增用户 
//2.存储积分消息日志 
commit transation;

这种情况下,本地数据库操作与存储积分消息日志处于同一个事务中,本地数据库操作与记录消息日志操作具备原 子性。

2)定时任务扫描日志

如何保证将消息发送给消息队列呢? 经过第一步消息已经写到消息日志表中,可以启动独立的线程,定时对消息日志表中的消息进行扫描并发送至消息中间件,在消息中间件反馈发送成功后删除该消息日志,否则等待定时任务下一周期重试。

3)消费消息

如何保证消费者一定能消费到消息呢?
这里可以使用MQ的ack(即消息确认)机制,消费者监听MQ,如果消费者接收到消息并且业务处理完成后向MQ 发送ack(即消息确认),此时说明消费者正常消费消息完成,MQ将不再向消费者推送消息,否则消费者会不断重 试向消费者来发送消息。
积分服务接收到”增加积分“消息,开始增加积分,积分增加成功后向消息中间件回应ack,否则消息中间件将重复 投递此消息。
由于消息会重复投递,积分服务的”增加积分“功能需要实现幂等性。

2、RocketMQ事务消息方案

RocketMQ 是一个来自阿里巴巴的分布式消息中间件,于 2012 年开源,并在 2017 年正式成为 Apache 顶级项 目。据了解,包括阿里云上的消息产品以及收购的子公司在内,阿里集团的消息产品全线都运行在 RocketMQ 之 上,并且最近几年的双十一大促中,RocketMQ 都有抢眼表现。Apache RocketMQ 4.3之后的版本正式支持事务消 息,为分布式事务实现提供了便利性支持。

RocketMQ 事务消息设计则主要是为了解决 Producer 端的消息发送与本地事务执行的原子性问题,RocketMQ 的 设计中 broker 与 producer 端的双向通信能力,使得 broker 天生可以作为一个事务协调者存在;而 RocketMQ 本身提供的存储机制为事务消息提供了持久化能力;RocketMQ 的高可用机制以及可靠消息设计则为事务消息在系 统发生异常时依然能够保证达成事务的最终一致性。

在RocketMQ 4.3后实现了完整的事务消息,实际上其实是对本地消息表的一个封装,将本地消息表移动到了MQ 内部,解决 Producer 端的消息发送与本地事务执行的原子性问题

1)Producer 发送事务消息

Producer (MQ发送方)发送事务消息至MQ Server,MQ Server将消息状态标记为Prepared(预备状态),注 意此时这条消息消费者(MQ订阅方)是无法消费到的。 本例中,Producer 发送 ”增加积分消息“ 到MQ Server。

2)MQ Server回应消息发送成功

MQ Server接收到Producer 发送给的消息则回应发送成功表示MQ已接收到消息。

3)Producer 执行本地事务

Producer 端执行业务代码逻辑,通过本地数据库事务控制。 本例中,Producer 执行添加用户操作。

4)消息投递

若Producer 本地事务执行成功则自动向MQServer发送commit消息,MQ Server接收到commit消息后将”增加积 分消息“ 状态标记为可消费,此时MQ订阅方(积分服务)即正常消费消息;
若Producer 本地事务执行失败则自动向MQServer发送rollback消息,MQ Server接收到rollback消息后 将删 除”增加积分消息“ 。
MQ订阅方(积分服务)消费消息,消费成功则向MQ回应ack,否则将重复接收消息。这里ack默认自动回应,即 程序执行正常则自动回应ack。

5)事务回查

如果执行Producer端本地事务过程中,执行端挂掉,或者超时,MQ Server将会不停的询问同组的其他 Producer 来获取事务执行状态,这个过程叫事务回查。MQ Server会根据事务回查结果来决定是否投递消息。以上主干流程已由RocketMQ实现,对用户侧来说,用户需要分别实现本地事务执行以及本地事务回查方法,因此 只需关注本地事务的执行状态即可。
RoacketMQ提供RocketMQLocalTransactionListener接口:
org.apache.rocketmq.spring.core.RocketMQLocalTransactionListener

public interface RocketMQLocalTransactionListener {
    RocketMQLocalTransactionState executeLocalTransaction(final Message msg, final Object arg);

    RocketMQLocalTransactionState checkLocalTransaction(final Message msg);
}

发送事务消息:
以下是RocketMQ提供用于发送事务消息的API

TransactionMQProducer producer = new TransactionMQProducer("ProducerGroup"); 
producer.setNamesrvAddr("127.0.0.1:9876"); 
producer.start(); 
//设置TransactionListener实现 
producer.setTransactionListener(transactionListener); 
//发送事务消息 
SendResult sendResult = producer.sendMessageInTransaction(msg, null);

1.3 RocketMQ实现可靠消息最终一致性事务

1、业务说明

本实例通过RocketMQ中间件实现可靠消息最终一致性分布式事务,模拟两个账户的转账交易过程。 两个账户在分别在不同的银行(张三在bank1、李四在bank2),bank1、bank2是两个微服务。交易过程是,张三 给李四转账指定金额
上述交易步骤,张三扣减金额与给bank2发转账消息,两个操作必须是一个整体性的事务。

2、程序组成部分

本示例程序组成部分如下:
数据库:MySQL-5.7.25 包括bank1和bank2两个数据库。
JDK:64位 jdk1.8.0_172
rocketmq 服务端:RocketMQ-4.8.0
rocketmq 客户端:RocketMQ-Spring-Boot-starter.2.0.2-RELEASE
微服务框架:spring-boot-2.1.3、spring-cloud-Greenwich.RELEASE
微服务及数据库的关系 :
txmsg-demo-bank1 银行1,操作张三账户, 连接数据库bank1
txmsg-demo-bank2 银行2,操作李四账户, 连接数据库bank2
本示例程序技术架构如下:
image.png

3、环境准备

1)数据库

参考tcc bank1,bank2数据库

2)mq
cd D:\developsoftware2\rocketmq-all-4.8.0-bin-release\bin
//启动 nameserver
start mqnamesrv.cmd
//启动broker
start mqbroker.cmd -n 127.0.0.1:9876 autoCreateTopicEnable=true

4、代码实现-配置

bank1,bank2共同配置
引入依赖:
父工程锁定依赖

	  <properties>
        <java.version>1.8</java.version>
        <spring.cloud.version>Greenwich.RELEASE</spring.cloud.version>
        <spring-cloud-alibaba.version>2.1.0.RELEASE</spring-cloud-alibaba.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring.cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>com.alibaba.cloud</groupId>
                <artifactId>spring-cloud-alibaba-dependencies</artifactId>
                <version>${spring-cloud-alibaba.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <dependency>
                <groupId>org.mybatis.spring.boot</groupId>
                <artifactId>mybatis-spring-boot-starter</artifactId>
                <version>1.3.2</version>
            </dependency>
            <dependency>
                <groupId>com.alibaba</groupId>
                <artifactId>druid-spring-boot-starter</artifactId>
                <version>1.1.10</version>
            </dependency>
            <dependency>
                <groupId>mysql</groupId>
                <artifactId>mysql-connector-java</artifactId>
                <version>5.1.47</version>
            </dependency>
            <dependency>
                <groupId>org.apache.rocketmq</groupId>
                <artifactId>rocketmq-spring-boot-starter</artifactId>
                <version>2.0.2</version>
            </dependency>
        </dependencies>
    </dependencyManagement>

子工程引入依赖:

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.rocketmq</groupId>
            <artifactId>rocketmq-spring-boot-starter</artifactId>
        </dependency>
    </dependencies>

bank1配置:application.yaml

server:
  port: 56081
spring:
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    password: mysql
    url: jdbc:mysql://localhost:3306/bank1?useUnicode=true
    username: root
swagger:
  enable: true

logging:
  level:
    org:
      springframework:
        web: info
    root: info
rocketmq:
  name-server: 127.0.0.1:9876
  producer:
    group: producer_bank1

bank2配置:application.yaml

rocketmq:
  name-server: 127.0.0.1:9876
  producer:
    group: producer_bank2
server:
  port: 56082
spring:
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    password: mysql
    url: jdbc:mysql://localhost:3306/bank2?useUnicode=true
    username: root
swagger:
  enable: true


logging:
  level:
    org:
      springframework:
        web: info
    root: info

5、代码实现-实现

txmsg-bank1-demo

实现如下功能:
1、张三扣减金额,提交本地事务。
2、向MQ发送转账消息。
Dao:

package com.zengqingfa.txmsg.bank1.dao;

import com.zengqingfa.txmsg.bank1.entity.AccountInfo;
import org.apache.ibatis.annotations.*;
import org.springframework.stereotype.Component;

@Mapper
@Component
public interface AccountInfoDao {

    @Update("update account_info set account_balance=account_balance+#{amount} where account_no=#{accountNo}")
    int updateAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);


    @Select("select * from account_info where where account_no=#{accountNo}")
    AccountInfo findByIdAccountNo(@Param("accountNo") String accountNo);



    @Select("select count(1) from de_duplication where tx_no = #{txNo}")
    int isExistTx(String txNo);


    @Insert("insert into de_duplication values(#{txNo},now());")
    int addTx(String txNo);

}

service:

public interface AccountInfoService {

    /**
     * 更新帐号余额‐发送消息
     * producer向MQ Server发送消息
     * @param accountChangeEvent
     */
    void sendUpdateAccountBalance(AccountChangeEvent accountChangeEvent);

    /**
     * 更新帐号余额‐更新账号余额
     * producer发送消息完成后接收到MQ Server的回应即开始执行本地事务
     * @param accountChangeEvent
     */
    void doUpdateAccountBalance(AccountChangeEvent accountChangeEvent);
}



package com.zengqingfa.txmsg.bank1.service.impl;

import com.alibaba.fastjson.JSONObject;
import com.zengqingfa.txmsg.bank1.dao.AccountInfoDao;
import com.zengqingfa.txmsg.bank1.model.AccountChangeEvent;
import com.zengqingfa.txmsg.bank1.service.AccountInfoService;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.client.producer.TransactionSendResult;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.Message;
import org.springframework.messaging.support.MessageBuilder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 *
 * @fileName: AccountInfoServiceImpl
 * @author: zengqf3
 * @date: 2021-4-22 15:40
 * @description:
 */
@Service
@Slf4j
public class AccountInfoServiceImpl implements AccountInfoService {

    @Autowired
    private AccountInfoDao accountInfoDao;

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    @Override
    public void sendUpdateAccountBalance(AccountChangeEvent accountChangeEvent) {
        String jsonString = JSONObject.toJSONString(accountChangeEvent);
        Message message = MessageBuilder.withPayload(jsonString).build();
        //发送事务消息
        TransactionSendResult sendResult = rocketMQTemplate
                .sendMessageInTransaction("txmsg_bank1_group", "txmsg_topic", message, null);
        log.info("sendResult:{}", JSONObject.toJSONString(sendResult));
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void doUpdateAccountBalance(AccountChangeEvent changeEvent) {
        //幂等控制
        if (accountInfoDao.isExistTx(changeEvent.getTxNo()) > 0) {
            return;
        }
        //更新
        accountInfoDao.updateAccountBalance(changeEvent.getAccountNo(), changeEvent.getAmount()*(-1));
        //插入事务记录
        accountInfoDao.addTx(changeEvent.getTxNo());
    }
}

RocketMQLocalTransactionListener:
编写RocketMQLocalTransactionListener接口实现类,实现执行本地事务和事务回查两个方法。

package com.zengqingfa.txmsg.bank1.mq;

import com.alibaba.fastjson.JSONObject;
import com.zengqingfa.txmsg.bank1.dao.AccountInfoDao;
import com.zengqingfa.txmsg.bank1.model.AccountChangeEvent;
import com.zengqingfa.txmsg.bank1.service.AccountInfoService;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.RocketMQTransactionListener;
import org.apache.rocketmq.spring.core.RocketMQLocalTransactionListener;
import org.apache.rocketmq.spring.core.RocketMQLocalTransactionState;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.Message;
import org.springframework.stereotype.Component;

/**
 *
 * @fileName: RocketMQLocalTransactionListener
 * @author: zengqf3
 * @date: 2021-4-22 16:03
 * @description:
 */
@RocketMQTransactionListener(txProducerGroup = "txmsg_bank1_group")
@Component
@Slf4j
public class ProducerTxmsgListener implements RocketMQLocalTransactionListener {

    @Autowired
    private AccountInfoService accountInfoService;
    @Autowired
    private AccountInfoDao accountInfoDao;

    /**
     * 执行本地事务
     * @param message
     * @param o
     * @return
     */
    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o) {
        try {
            String s = new String((byte[]) message.getPayload());
            AccountChangeEvent changeEvent = JSONObject.parseObject(s, AccountChangeEvent.class);
            accountInfoService.doUpdateAccountBalance(changeEvent);
            return RocketMQLocalTransactionState.COMMIT;
        } catch (Exception e) {
            log.error("本地事务执行失败,e:{}", e.getMessage());
            return RocketMQLocalTransactionState.ROLLBACK;
        }
    }

    /**
     * 消息回查
     * @param message
     * @return
     */
    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
        RocketMQLocalTransactionState state;
        String s = new String((byte[]) message.getPayload());
        AccountChangeEvent changeEvent = JSONObject.parseObject(s, AccountChangeEvent.class);
        String txNo = changeEvent.getTxNo();
        int existTx = accountInfoDao.isExistTx(txNo);
        log.info("事务回查,txNo={},回查结果:{}", txNo, existTx);
        if (existTx > 0) {
            state = RocketMQLocalTransactionState.COMMIT;
        } else {
            state = RocketMQLocalTransactionState.UNKNOWN;
        }
        return state;
    }
}

controller:

package com.zengqingfa.txmsg.bank1.controller;

import com.zengqingfa.txmsg.bank1.model.AccountChangeEvent;
import com.zengqingfa.txmsg.bank1.service.AccountInfoService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.UUID;

/**
 *
 * @fileName: AccountInfoController
 * @author: zengqf3
 * @date: 2021-4-22 16:27
 * @description:
 */
@RestController
@Slf4j
public class AccountInfoController {
    @Autowired
    private AccountInfoService accountInfoService;

    @GetMapping(value = "/transfer")
    public String transfer(@RequestParam("accountNo") String accountNo, @RequestParam("amount") Double amount) {
        String tx_no = UUID.randomUUID().toString();
        AccountChangeEvent accountChangeEvent = new AccountChangeEvent(accountNo, amount, tx_no);
        accountInfoService.sendUpdateAccountBalance(accountChangeEvent);
        return "转账成功";
    }
}

txmsg-bank2-demo

需要实现如下功能:
1、监听MQ,接收消息。
2、接收到消息增加账户金额。
entity:

@Data
@AllArgsConstructor
@NoArgsConstructor
public class AccountChangeEvent implements Serializable {
    /**
     * 账号
     */
    private String accountNo;
    /**
     * 变动金额
     */
    private double amount;
    /**
     * 事务号
     */
    private String txNo;

}

Dao:

package com.zengqingfa.txmsg.bank2.dao;

import org.apache.ibatis.annotations.*;
import org.springframework.stereotype.Component;

@Mapper
@Component
public interface AccountInfoDao {
    @Update("update account_info set account_balance=account_balance+#{amount} where account_no=#{accountNo}")
    int updateAccountBalance(@Param("accountNo") String accountNo, @Param("amount") Double amount);

    @Select("select count(1) from de_duplication where tx_no = #{txNo}")
    int isExistTx(String txNo);

    @Insert("insert into de_duplication values(#{txNo},now());")
    int addTx(String txNo);

}

service:

public interface AccountInfoService {

    /**
     * 更新账号金额
     * @param accountChangeEvent
     */
    void updateAccountInfoBalance(AccountChangeEvent accountChangeEvent);

}

package com.zengqingfa.txmsg.bank2.service.impl;

import com.alibaba.fastjson.JSONObject;
import com.zengqingfa.txmsg.bank2.dao.AccountInfoDao;
import com.zengqingfa.txmsg.bank2.model.AccountChangeEvent;
import com.zengqingfa.txmsg.bank2.service.AccountInfoService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 *
 * @fileName: AccountInfoServiceImpl
 * @author: zengqf3
 * @date: 2021-4-22 16:59
 * @description:
 */
@Slf4j
@Service
public class AccountInfoServiceImpl implements AccountInfoService {

    @Autowired
    private AccountInfoDao accountInfoDao;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateAccountInfoBalance(AccountChangeEvent accountChangeEvent) {
        log.info("updateAccountInfoBalance参数:{}", JSONObject.toJSONString(accountChangeEvent));
        //幂等
        if (accountInfoDao.isExistTx(accountChangeEvent.getTxNo()) > 0) {
            log.info("重复执行,txNo:{}", accountChangeEvent.getTxNo());
            return;
        }
        //更新金额
        accountInfoDao.updateAccountBalance(accountChangeEvent.getAccountNo(), accountChangeEvent.getAmount());
        //添加事务记录
        accountInfoDao.addTx(accountChangeEvent.getTxNo());
    }
}

MQ监听类:

package com.zengqingfa.txmsg.bank2.mq;

import com.alibaba.fastjson.JSONObject;
import com.zengqingfa.txmsg.bank2.model.AccountChangeEvent;
import com.zengqingfa.txmsg.bank2.service.AccountInfoService;
import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

/**
 *
 * @fileName: TxmsgConsumer
 * @author: zengqf3
 * @date: 2021-4-22 17:05
 * @description:
 */
@RocketMQMessageListener(topic = "txmsg_topic", consumerGroup = "txmsg_consumer_group")
@Slf4j
@Component
public class TxmsgConsumer implements RocketMQListener<AccountChangeEvent> {

    @Autowired
    private AccountInfoService accountInfoService;

    @Override
    public void onMessage(AccountChangeEvent changeEvent) {
        log.info("开始消费消息:{}", JSONObject.toJSONString(changeEvent));
        //增加金额
        changeEvent.setAccountNo("2");
        accountInfoService.updateAccountInfoBalance(changeEvent);
    }
}

6、测试

  • bank1本地事务失败,则bank1不发送转账消息。
  • bank2接收转账消息失败,会进行重试发送消息。
  • bank2多次消费同一个消息,实现幂等。

1.4 小结

可靠消息最终一致性就是保证消息从生产方经过消息中间件传递到消费方的一致性,本案例使用了RocketMQ作为 消息中间件,RocketMQ主要解决了两个功能:
1、本地事务与消息发送的原子性问题。
2、事务参与方接收消息的可靠性。
可靠消息最终一致性事务适合执行周期长且实时性要求不高的场景。引入消息机制后,同步的事务操作变为基于消 息执行的异步操作, 避免了分布式事务中的同步阻塞操作的影响,并实现了两个服务的解耦。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值