分析Canal如何支持MySQL主从同步

本文结合MySQL提供的协议,逐步分析Canal从连接到订阅binlog这一过程。

1、Canal连接MySQL

MySQL初始化完成之后,就会监听并准备接收来自外部的连接请求。它将这一逻辑交给handle_connections_methods()函数去完成。该函数主要监听三种连接方式,分别是命名管道(namedpipes)、TCP/IP(sockets)以及共享内存(shared_memory)。一般我们的客户端(例如JDBC驱动和mysqld)都是用TCP/IP的方式连接MySQL服务器,而后两种一般在嵌入式开发用的比较多。

Canal采用TCP/IP的方式模拟MySQL连接协议交互,所以下面我们也会以TCP/IP的维度去分析Canal如何连接上MySQL节点。

1.1、协议数据包

在介绍如何连接前,我们需要知道MySQL数据包的组成。和众多基于TCP开发的应用一样,MySQL为了解决粘包问题,自定义了自己数据包的帧格式。所有的数据包均有header和body两部分组成。

header由data length和sequence_id两部分组成,共4个字节,其中data length表示body数据长度的,占3个字节,这也导致Server发送到Client的最大packet大小为(2^24−1) bytes,也就是16M,超出的大小将会被分到不同的packet中;而sequence_id仅有1个字节,目的是防止串包。机制是每收到一个报文都在其 sequenceId 上加 1,并随着需要返回的信息返回回去。如果 DB 检测到 sequenceId 连续,则表明没有串包。如果不连续,则串包,DB 会直接丢弃这个连接。除此之外,分包的后每个包sequence依次加1。

body表示具体传输的数据,也就是有效负载,不同的协议这部分传递的信息不同。

1.2、连接过程

MySQL的连接阶段主要包含“握手”和“认证”两个步骤,不管是Canal、slave还是其他jdbc服务,都要经过以下步骤,具体参考下图。

在这里插入图片描述

在MySQL和客户端建立了TCP连接后,Server端会优先再一次发起一次握手请求,这个过程也是三次握手。该握手过程主要涉及以下任务:

  1. Server和Client互相交换能力(capabilities);
  2. 根据Client需要选择建立基于SSL的通道;
  3. 针对服务器对客户端进行身份验证(authenticate the client against the server)。

下面我们来逐点分析:

1.2.1、Initial Handshake

在MySQL中,Server会在TCP的三次握手后主动发送请求,而第一阶段就是发送Handshake Packet,该packet中主要包含MySQL服务端的一些参数信息,例如服务端的版本号,通信过程中的最大消息长度,编码信息以及用于Client认证选取的方法等等。(注意MySQL3.21.0后的版本都是发送Protocol::HandshakeV10版本,其他版本忽略)

相关字段解析如下:

protocol_version:服务器协议版本号,默认为10。
server_version:服务器的版本号。
thread id:服务器为客户端分配的线程id,可以通过SHOW PROCESSLIST看到。
auth-plugin-data-part-1:服务器生成的用于验证随机字符串(有的地方叫挑战随机数)的前8个字节。
filter:默认0x00的填充值
capability_flags_1:服务端的能力(capabilities)低两个字节
character_set:字符编码号。
status_flags:服务器状态。
capability_flags_2:服务端的能力(capabilities)高两个字节
auth_plugin_data_len: 随机字符串总长度
reserved:默认0x00填充值
auth-plugin-data-part-2:服务器生成的用于验证随机字符串数据的剩余字节。
auth_plugin_name:用户校验插件名

注:相关代码在文件sql/sql_acl.cc的函数send_server_handshake_packet中

Handshake Packet这里需要重点关注和用户认证相关的auth_plugin_name、auth-plugin-data-part-1/2,以及表示服务端能力的capability_flags_1/2。

先来看第一个东西,挑战随机数。MySQL数据库用户认证采用的是挑战/应答的方式,服务器生成该挑战数并发送给客户端,由客户端进行处理并返回相应结果,然后服务器检查是否与预期的结果相同,从而完成用户认证的过程。这里涉及到MySQL的认证过程:

a、MySQL将用户的密码已密文的形式存储在mysql.user表的password字段中,密文的加密方式是通过对明文密码的进行两次SHA1计算出来的哈希值,代码中称这计算出来的哈希值为stage2hash。也就是说stage2hash的计算方式为:

                                               stage2hash = SHA1(SHA1(明文密码))

这里猜测MySQL的设计者故意进行两次SHA1的理由是怕用户的密码设置比较简单,通过暴力方式还是可以解出比较简单的密码,第二次SHA1的参数会是一个复杂无规则的160位字符串。所以即使这个哈希值被盗取了,依然无法解析出原本的明文密码。

b、客户端将MySQL发送过来的挑战随机数(代码中称为scramble),同样进行SHA1的加密生成密钥key,计算方式是:

                                                 key = SHA1(scramble|stage2hash)

客户端将收到的scramble和本地计算的stage2hash一起SHA1后,生成密钥,并将密钥和stage1hash进行加密,加密的方式是对key和stage1hash进行一次XOR操作,而stage1hash是对明文密码进行一次SHA1后的结果,也就是

                                    发送给服务端的密码密文 = XOR(key,SHA1(明文密码))

c、服务端分析密文是否正确也很简单,将key和客户端发送的密文进行一次XOR,得到stage1hash,然后再对stage1hash进行一次SHA1,将得到的结果和数据库存的进行比较就行。

其次第二个东西是表示服务端能力的capability_flags_1/2,MySQL版本比较多,客户端和服务端的版本不一定匹配,客户端需要的功能,或者服务端支持的功能之间需要有一个协商,握手中会传递这一部分。协商时主要依赖capabilities flag,其中包括是否支持压缩功能、长密码和SSL等等

1.2.2、HandShake Response

Client在收到来自Server的Handshake Packet之后,可以根据需要选择基于SSL的通信,还是普通文本。这里我们介绍下基于SSL的连接,注意支持SSL的前提是需要前面服务端给的capabilities有提供CLIENT_SSL 能力。

当服务端发送完Protocol::Handshake数据包后,客户端可以发送Protocol::SSLRequest建立SSL连接,其中的SSLRequest packet payload如下:

 需要注意一点是,当请求SSL连接时,需要把CLIENT_SSL capabitities塞入到请求中。

1.3.3、认证成功

假如认证成功后, 服务端会向客户端返回 OK_Packet,认证失败, 会向客户端返回 ERR_Packet。

  • OK_Packet:表示Client发送的命令执行成功,第1个字节值等于 00
  • ERR_Packet:表示发生异常,第1个字节值等于 FF,对应10进制为-1

所以需要判断认证成功还是失败,只需要判断接收到的包第一个字节值是否为10进制的-1 还是0

1.3.4、Canal和MySQL连接分析

这里分析的是Canal和MySQL建立了TCP之后的,Handshake Packet和SSLRequest Packet的交互,实现放在了MysqlConnector.negotiate方法中。

    // MysqlConnector.java
    private void negotiate(SocketChannel channel) throws IOException {
        // Mysql发出的包,header大小都为4个字节
        HeaderPacket header = PacketManager.readHeader(channel, 4, timeout);
        // 获取到header之后,取前面3个字节组成的数字即为接下来packet body也就是payload大小
        byte[] body = PacketManager.readBytes(channel, header.getPacketBodyLength(), timeout);
        if (body[0] < 0) {// check field_count
            if (body[0] == -1) {
                ErrorPacket error = new ErrorPacket();
                error.fromBytes(body);
                throw new IOException("handshake exception:\n" + error.toString());
            } else if (body[0] == -2) {
                throw new IOException("Unexpected EOF packet at handshake phase.");
            } else {
                throw new IOException("unpexpected packet with field_count=" + body[0]);
            }
        }
        HandshakeInitializationPacket handshakePacket = new HandshakeInitializationPacket();
        // 将获取过来的body解析为HandshakeInitializationPacket包
        handshakePacket.fromBytes(body);
        connectionId = handshakePacket.threadId; // 记录一下connection

        logger.info("handshake initialization packet received, prepare the client authentication packet to send");

        // 这里canal用的是SSL连接,返回的是SSLRequest
        ClientAuthenticationPacket clientAuth = new ClientAuthenticationPacket();
        clientAuth.setCharsetNumber(charsetNumber);

        clientAuth.setUsername(username);
        clientAuth.setPassword(password);
        // 注意这里canal把server发送过来的全部capabilities发送获取
        clientAuth.setServerCapabilities(handshakePacket.serverCapabilities);
        clientAuth.setDatabaseName(defaultSchema);
        // 拼接随机挑战数的高低位
        clientAuth.setScrumbleBuff(joinAndCreateScrumbleBuff(handshakePacket));

        byte[] clientAuthPkgBody = clientAuth.toBytes();
        HeaderPacket h = new HeaderPacket();
        h.setPacketBodyLength(clientAuthPkgBody.length);
        // sequence + 1
        h.setPacketSequenceNumber((byte) (header.getPacketSequenceNumber() + 1));

        // 将SSL Request写到和mysql连接的channel中
        PacketManager.writePkg(channel, h.toBytes(), clientAuthPkgBody);
        logger.info("client authentication packet is sent out.");

        // 再次获取server发送过来的packet,检查验证是否成功
        header = null;
        header = PacketManager.readHeader(channel, 4);
        body = null;
        body = PacketManager.readBytes(channel, header.getPacketBodyLength(), timeout);
        assert body != null;
        // 不为0则表示认证失败
        if (body[0] < 0) {
            // -1表示Err_packet
            if (body[0] == -1) {
                ErrorPacket err = new ErrorPacket();
                err.fromBytes(body);
                throw new IOException("Error When doing Client Authentication:" + err.toString());
            } else if (body[0] == -2) {
                auth323(channel, header.getPacketSequenceNumber(), handshakePacket.seed);
                // throw new
                // IOException("Unexpected EOF packet at Client Authentication.");
            } else {
                throw new IOException("unpexpected packet with field_count=" + body[0]);
            }
        }
    }

2、Register

成功连接到Mysql后,任何slave节点在获取binlog前,都需要向master发送COM_REGISTER_SLAVE命令进行注册。该命令的payload如下(左边数字表示长度,下同):

1              [15] COM_REGISTER_SLAVE,第一个字节值恒定为16进制的15,如果划算成10进制就是21
4              server-id,slave的server id,不同的slave必须有不同的slaveId
1              slaves hostname length,slave的host信息长度
string[$len]   slaves hostname,slave的host信息
1              slaves user len,slave用于连接master的账号名长度
string[$len]   slaves user,slave用于连接master的账号名,需要有replication slave和replication client 权限
1              slaves password len,slave用于连接master的密码长度
string[$len]   slaves password,slave用于连接master的密码
2              slaves mysql-port,slave的端口
4              replication rank,这个估计没用的字段,忽略即可
4              master-id,通常为0

在MySQL Replication中,都需要使用一个唯一server id来区别不同的server实例。

canal在 RegisterSlaveCommandPacket 类中实现了关于COM_BINLOG_DUMP命令的封装

public class RegisterSlaveCommandPacket extends CommandPacket {

    // implement by parent
    private byte command;

    public String reportHost;
    public int    reportPort;
    public String reportUser;
    public String reportPasswd;
    public long   serverId;

    public RegisterSlaveCommandPacket(){
        setCommand((byte) 0x15);
    }

    // implement by parent
    public byte getCommand() {
        return command;
    }

    public byte[] toBytes() throws IOException {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        out.write(getCommand());
        ByteHelper.writeUnsignedIntLittleEndian(serverId, out);
        out.write((byte) reportHost.getBytes().length);
        ByteHelper.writeFixedLengthBytesFromStart(reportHost.getBytes(), reportHost.getBytes().length, out);
        out.write((byte) reportUser.getBytes().length);
        ByteHelper.writeFixedLengthBytesFromStart(reportUser.getBytes(), reportUser.getBytes().length, out);
        out.write((byte) reportPasswd.getBytes().length);
        ByteHelper.writeFixedLengthBytesFromStart(reportPasswd.getBytes(), reportPasswd.getBytes().length, out);
        ByteHelper.writeUnsignedShortLittleEndian(reportPort, out);
        ByteHelper.writeUnsignedIntLittleEndian(0, out);// Fake
                                                        // rpl_recovery_rank
        ByteHelper.writeUnsignedIntLittleEndian(0, out);// master id
        return out.toByteArray();
    }
    
    ...
}

注意,在toBytes()方法里面拼装数据时,用了小端模式。MySQL的报文采用的是小端模式(而PostgreSQL采用的则是大端模式)

3、Binlog Dump

MySQL支持两种binlog dump的命令,分别是指定binlog filename + position的COM_BINLOG_DUMP,以及通过指定GTID的 COM_BINLOG_DUMP_GTID。

3.1、COM_BINLOG_DUMP

该命令用于根据指定的binlog文件位置,从Master中获取binlog文件流。

1              [12] COM_BINLOG_DUMP 16进制的12,表示该命令为获取binlog流
4              binlog-pos 指定binlog文件的位置
2              flags 目前只有一个值,也就是BINLOG_DUMP_NON_BLOCK(0X01)
4              server-id slave的server_id
string[EOF]    binlog-filename binlog文件名

注意点1:并非需要指定binlog filename,没有指定时将默认从Master的第一个binlog文件开始消费起;(可以通过SHOW BINARY LOGS知道当前第一个binlog的文件和大小)

注意点2:关于payload的第三个参数flags,目前仅有一个值就是0x01,表示BINLOG_DUMP_NON_BLOCK,该值的意思是当没有更多的binlog event时,就返回一个EOF Packet给Slave,而不是阻塞当前连接;

注意点3:Master接收到该指令后,并非会返回binlog event或者时EOF Package,有可能也会返回一个Error Packet,例如找不到指定的binlog文件或者是没有复制权限。

canal在 BinlogDumpCommandPacket 实现发送请求binlog 命令:

    public byte[] toBytes() throws IOException {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        // 0. write command number
        out.write(getCommand());
        // 1. write 4 bytes bin-log position to start at
        ByteHelper.writeUnsignedIntLittleEndian(binlogPosition, out);
        // 2. write 2 bytes bin-log flags
        int binlog_flags = 0;
        binlog_flags |= BINLOG_SEND_ANNOTATE_ROWS_EVENT;
        out.write(binlog_flags);
        // 补0x00 是因为flags长度不够2个字节
        out.write(0x00);
        // 3. write 4 bytes server id of the slave
        ByteHelper.writeUnsignedIntLittleEndian(this.slaveServerId, out);
        // 4. write bin-log file name if necessary
        if (StringUtils.isNotEmpty(this.binlogFileName)) {
            out.write(this.binlogFileName.getBytes());
        }
        return out.toByteArray();
    }

这里canal为兼容MariaDB,指定binlog flags值为BINLOG_SEND_ANNOTATE_ROWS_EVENT,该值对应0x10,在MySQL中是无效的,所以MySQL当没有binlog event时,不会发送EOF给canal,而是阻塞当前线程。

但是当源是MariaDB时,该参数的值0x10表示允许发送AnnotateRowsEvent,否则以空的QueryLogEvent来代替。

3.2、COM_BINLOG_DUMP_GTID

跳过(●'◡'●)

4、Binlog Event

当canal发送完Binlog Dump的命令之后,Mysql就会开始推送将一个个binlog event推送给canal。需要注意的是,每次Mysql都只会推送一个event给下游的slave,我们先来看下binlog event packet结构。

4.1、binlog event packet

每个event包括Binlog event header,Post-Header和Body三部分,注意区分这里的EventHeader和MySQL发送过来的packet的header,后者是MySQL网络层发送时自动加上的,非binlog字节管理的部分。

对于所有event而言,Binlog Eventheader的长度与格式是相同的,Post-Header对于同类型的event是长度相同的,部分类型的event并没有Post-Header。而Body则为event中的最终的部分,表示具体信息,同样也不是所有的event都会有body,FORMAT_DESCRIPTION_EVENT就没有。

EventHeader表示通用的事件头,其结构如下:

4              timestamp
1              event type
4              server-id
4              event-size
   if binlog-version > 1:
4              log pos
2              flags
  • timestamp (4) -- Unix事件戳,表示event产生的事件

  • event_type (1) -- binlog 事件类型

  • server_id (4) --  产生事件的mysql serverId

  • event_size (4) -- 事件大小,这里的大小=EventHader自身 + EventBody

  • log_pos (4) -- 下一个事件的位点,也就是binlog文件的position

  • flags (2) --  binlog事件标识

canal将获取binlog event的实现放在类DirectLogFetcher.fetch方法中。

EventBody就不介绍了,不同的事件其结构有不同,详情可以查看binlog event 列表

4.2、Canal获取binlog event stream

canal获取binlog event流的实现逻辑是在DirectLogFetcher.fetch方法中

    // DirectLogFetcher
    public boolean fetch() throws IOException {
        try {
            // Fetching packet header from input.
            // 从MySQL的连接中(默认是Socket)获取消息头,由上面的介绍可知header大小为4个字节
            // 所以这里的NET_HEADER_SIZE值是4
            if (!fetch0(0, NET_HEADER_SIZE)) {
                logger.warn("Reached end of input stream while fetching header");
                return false;
            }

            // Fetching the first packet(may a multi-packet).
            // header的前3个字节表示body的长度
            int netlen = getUint24(PACKET_LEN_OFFSET);
            // header的最后一个字节表示sequenceId
            int netnum = getUint8(PACKET_SEQ_OFFSET);
            // 根据header给出的body大小,获取payload
            if (!fetch0(NET_HEADER_SIZE, netlen)) {
                logger.warn("Reached end of input stream: packet #" + netnum + ", len = " + netlen);
                return false;
            }

            // mark是4个字节的整形数字,表示当前同步是否异常
            final int mark = getUint8(NET_HEADER_SIZE);
            if (mark != 0) {
                if (mark == 255) // error from master
                {
                    // Indicates an error, for example trying to fetch from
                    // wrong
                    // binlog position.
                    position = NET_HEADER_SIZE + 1;
                    final int errno = getInt16();
                    String sqlstate = forward(1).getFixString(SQLSTATE_LENGTH);
                    String errmsg = getFixString(limit - position);
                    throw new IOException("Received error packet:" + " errno = " + errno + ", sqlstate = " + sqlstate
                                          + " errmsg = " + errmsg);
                } else if (mark == 254) {
                    // Indicates end of stream. It's not clear when this would
                    // be sent.
                    logger.warn("Received EOF packet from server, apparent"
                                + " master disconnected. It's may be duplicate slaveId , check instance config");
                    return false;
                } else {
                    // Should not happen.
                    throw new IOException("Unexpected response " + mark + " while fetching binlog: packet #" + netnum
                                          + ", len = " + netlen);
                }
            }

            // 当前mysql是否处于半同步状态
            if (issemi) {
                // parse semi mark
                int semimark = getUint8(NET_HEADER_SIZE + 1);
                int semival = getUint8(NET_HEADER_SIZE + 2);
                this.semival = semival;
            }

            // 就前面知道MySQL一个packet的payload最大是16M,这里通过判断payload是否
            // 为16M进行组包,当payload大小为16M时,则需要从socket中获取下一个packet,
            // 组装成一个完整的event再返回给调用方,知道payload的大小不再是16M。
            // 那么如果event的大小刚好是16M的整数倍会怎样,此时MySQL会在发送完完整的event
            // 之后,再发送一个body长都为0的packet表示结束。
            while (netlen == MAX_PACKET_LENGTH) {
                if (!fetch0(0, NET_HEADER_SIZE)) {
                    logger.warn("Reached end of input stream while fetching header");
                    return false;
                }

                netlen = getUint24(PACKET_LEN_OFFSET);
                netnum = getUint8(PACKET_SEQ_OFFSET);
                if (!fetch0(limit, netlen)) {
                    logger.warn("Reached end of input stream: packet #" + netnum + ", len = " + netlen);
                    return false;
                }
            }

            // Preparing buffer variables to decoding.
            if (issemi) {
                origin = NET_HEADER_SIZE + 3;
            } else {
                origin = NET_HEADER_SIZE + 1;
            }
            position = origin;
            limit -= origin;
            return true;
        } catch (SocketTimeoutException e) {
            close(); /* Do cleanup */
            logger.error("Socket timeout expired, closing connection", e);
            throw e;
        } catch (InterruptedIOException e) {
            close(); /* Do cleanup */
            logger.info("I/O interrupted while reading from client socket", e);
            throw e;
        } catch (ClosedByInterruptException e) {
            close(); /* Do cleanup */
            logger.info("I/O interrupted while reading from client socket", e);
            throw e;
        } catch (IOException e) {
            close(); /* Do cleanup */
            logger.error("I/O error while reading from client socket", e);
            throw e;
        }
    }

MysqlConnection.seek负责调用DirectLogFetcher.fetch方法,在循环中不断获取binlog event

    public void seek(String binlogfilename, Long binlogPosition, SinkFunction func) throws IOException {
        updateSettings();

        // 设置binlog位点
        sendBinlogDump(binlogfilename, binlogPosition);
        DirectLogFetcher fetcher = new DirectLogFetcher(connector.getReceiveBufferSize());
        fetcher.start(connector.getChannel());
        LogDecoder decoder = new LogDecoder();
        // 指定需要获取的binlog事件
        decoder.handle(LogEvent.ROTATE_EVENT);
        decoder.handle(LogEvent.FORMAT_DESCRIPTION_EVENT);
        decoder.handle(LogEvent.QUERY_EVENT);
        decoder.handle(LogEvent.XID_EVENT);
        LogContext context = new LogContext();
        while (fetcher.fetch()) {
            accumulateReceivedBytes(fetcher.limit());
            LogEvent event = null;
            // 将socket中拿到的二进制流解析成对应的binlog事件
            event = decoder.decode(fetcher, context);

            if (event == null) {
                throw new CanalParseException("parse failed");
            }

            // 调用sink方法将event暂时存储到EventTransactionBuffer中
            if (!func.sink(event)) {
                break;
            }
        }
    }

(将从socket获取到二进制流解析成对应的event步骤跳过,T_T event 类型太多文章写不下来,就知道有这个步骤就行)

canal从socket中获取到完整的event并解析完成后,在sink方法就会调用EventTransactionBuffer.add方法放入到buffer中。

    // EventTransactionBuffer.java
    public void add(CanalEntry.Entry entry) throws InterruptedException {
        switch (entry.getEntryType()) {
            case TRANSACTIONBEGIN:
                flush();// 刷新上一次的数据
                put(entry);
                break;
            case TRANSACTIONEND:
                put(entry);
                flush();
                break;
            case ROWDATA:
                put(entry);
                // 针对非DML的数据,直接输出,不进行buffer控制
                EventType eventType = entry.getHeader().getEventType();
                if (eventType != null && !isDml(eventType)) {
                    flush();
                }
                break;
            case HEARTBEAT:
                // master过来的heartbeat,说明binlog已经读完了,是idle状态
                put(entry);
                flush();
                break;
            default:
                break;
        }
    }

    private void put(CanalEntry.Entry data) throws InterruptedException {
        // 首先检查是否有空位
        if (checkFreeSlotAt(putSequence.get() + 1)) {
            long current = putSequence.get();
            long next = current + 1;

            // 先写数据,再更新对应的cursor,并发度高的情况,putSequence会被get请求可见,拿出了ringbuffer中的老的Entry值
            entries[getIndex(next)] = data;
            putSequence.set(next);
        } else {
            flush();// buffer区满了,刷新一下
            put(data);// 继续加一下新数据
        }
    }

canal会在检测到事件类型为事件头,事件尾,非DML语句(例如DDL),心跳事件或者buffer满了之后,会立即调用sink模块判断是否需要过滤该事件,随后再调用store模块写入目标存储。

关于buffer,其本质是一个内存数组,大小由配置参数canal.instance.transaction.size决定,默认是1024,这个参数主要控制parser解析后,提交到event store时,能够保证的事务一致性的数量。例如当你提交一个大事务时,其中的event数量超出了1024,此时buffer只能保证以1024为单位的event能够被一次性刷入到event store。按作者的说法,目前get数据获取时,暂时没有考虑事务完整读取的机制,主要还是考虑业务需求,对于事务完整性不敏感. 要保证完整读取其实也不难。

这里简单介绍下Canal如何获取binlog二进制流,和暂存的buffer,至于详细的parse模块、sink和store模块,甚至Canal的具体架构,可以看下田工的博客 田守枝canal源码分析

5、参考文档 

1、田守枝canal源码分析

2、MySQL官方主从同步协议介绍

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值