Canal--介绍及原理

1、Canal介绍

  • canal是阿里巴巴的一个使用Java开发的开源项目,基于数据库增量日志解析,提供增量数据订阅&消费,目前主要支持了MySQL(也支持mariaDB)。
  • 起源:早期,阿里巴巴B2B公司因为存在杭州和美国双机房部署,存在跨机房同步的业务需求。不过早期的数据库同步业务,主要是基于trigger的方式获取增量变更,不过从2010年开始,阿里系公司开始逐步的尝试基于数据库的日志解析,获取增量变更进行同步,由此衍生出了增量订阅&消费的业务,从此开启了一段新纪元。
  • 它是专门用来进行数据库同步的,基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费
  • 目前支持mysql、以及(mariaDB)
  • 从 2010 年开始,业务逐步尝试数据库日志解析获取增量变更进行同步,由此衍生出了大量的数据库增量订阅和消费业务,基于日志增量订阅和消费的业务包括
    • 数据库镜像
    • 数据库实时备份
    • 索引构建和实时维护(拆分异构索引、倒排索引等)
    • 业务 cache 刷新
    • 带业务逻辑的增量数据处理

2、Canal原理

在这里插入图片描述
在这里插入图片描述

  • Canal模拟mysql slave的交互协议,伪装自己为mysql slave
  • 向mysql master发送dump协议
  • mysql master收到dump协议,发送binary log给slave(也就是canal)
  • canal解析binary log字节流对象(原始为byte流)在这里插入图片描述
  • server 代表一个 canal 运行实例,对应于一个 jvm
  • instance 对应于一个数据队列 (1个 canal server 对应 1…n 个 instance )
  • instance 下的子模块
    • eventParser: 数据源接入,模拟 slave 协议和 master 进行交互,协议解析
    • eventSink: Parser 和 Store 链接器,进行数据过滤,加工,分发的工作
    • eventStore: 数据存储
    • metaManager: 增量订阅 & 消费信息管理器

3、MySQL主备复制原理

在这里插入图片描述

  • MySQL master 将数据变更写入二进制日志( binary log, 其中记录叫做二进制日志事件 log events,可以通过 show binlog events 进行查看)
  • MySQL slave 将 master 的 binary log events 拷贝到它的中继日志(relay log)
  • MySQL slave 重放 relay log 中事件,将数据变更反映它自己的数据,以此来达到数据一致。

mysql的binlog

  • 它记录了所有的DDL和DML(除了数据查询语句)语句,以事件形式记录,还包含语句所执行的消耗的时间。主要用来备份和数据同步。
  • binlog 有三种: STATEMENT、ROW、MIXED
    • STATEMENT 记录的是执行的sql语句
    • ROW 记录的是真实的行数据记录
    • MIXED 记录的是1+2,优先按照1的模式记录
  • 什么是中继日志?
    • 从服务器I/O线程将主服务器的二进制日志读取过来记录到从服务器本地文件,然后从服务器SQL线程会读取relay-log日志的内容并应用到从服务器,从而使从服务器和主服务器的数据保持一致

4、server/client交互协议

  • canal client与canal server之间是C/S模式的通信,客户端采用NIO,服务端采用Netty。canal server启动后,如果没有canal client,那么canal server不会去mysql拉取binlog。即Canal客户端主动发起拉取请求,服务端才会模拟一个MySQL Slave节点去主节点拉取binlog。通常Canal客户端是一个死循环,这样客户端一直调用get方法,服务端也就会一直拉取binlog

BIO、NIO、AIO的区别

  • IO的方式通常分为几种,同步阻塞的BIO、同步非阻塞的NIO、异步非阻塞的AIO。
  • 同步阻塞IO:
    • 在此种方式下,用户进程在发起一个IO操作以后,必须等待IO操作的完成,只有当真正完成了IO操作以后,用户进程才能运行。
    • JAVA传统的IO模型属于此种方式!
  • 同步非阻塞IO:
    • 在此种方式下,用户进程发起一个IO操作以后边可返回做其它事情,但是用户进程需要时不时的询问IO操作是否就绪,这就要求用户进程不停的去询问,从而引入不必要的CPU资源浪费。其中目前JAVA的NIO就属于同步非阻塞IO。
  • 异步阻塞IO:
    • 此种方式下是指应用发起一个IO操作以后,不等待内核IO操作的完成,等内核完成IO操作以后会通知应用程序,这其实就是同步和异步最关键的区别,同步必须等待或者主动的去询问IO是否完成,那么为什么说是阻塞的呢?
    • 因为此时是通过select系统调用来完成的,而select函数本身的实现方式是阻塞的,而采用select函数有个好处就是它可以同时监听多个文件句柄,从而提高系统的并发性!
  • 异步非阻塞IO:
    • 在此种模式下,用户进程只需要发起一个IO操作然后立即返回,等IO操作真正的完成以后,应用程序会得到IO操作完成的通知,
    • 此时用户进程只需要对数据进行处理就好了,不需要进行实际的IO读写操作,因为真正的IO读取或者写入操作已经由内核完成了。
    • 目前Java中还没有支持此种IO模型。
  • 参考资料:https://www.cnblogs.com/straybirds/p/9479158.html

5、交互流程

  • canal client与canal server之间属于增量订阅/消费,流程图如下:(其中C端是canal client,S端是canal server)在这里插入图片描述
  • canal client调用connect()方法时,发送的数据包(PacketType)类型为:
    • handshake,
    • ClientAuthentication。
  • canal client调用subscribe()方法,类型为[subscription]。
  • 对应服务端采用netty处理RPC请求(CanalServerWithNetty):
public class CanalServerWithNetty extends AbstractCanalLifeCycle implements CanalServer {
    public void start() {
        bootstrap.setPipelineFactory(new ChannelPipelineFactory() {
            public ChannelPipeline getPipeline() throws Exception {
                ChannelPipeline pipelines = Channels.pipeline();
                pipelines.addLast(FixedHeaderFrameDecoder.class.getName(), new FixedHeaderFrameDecoder());
                // 处理客户端的HANDSHAKE请求
                pipelines.addLast(HandshakeInitializationHandler.class.getName(),
                    new HandshakeInitializationHandler(childGroups));
                // 处理客户端的CLIENTAUTHENTICATION请求
                pipelines.addLast(ClientAuthenticationHandler.class.getName(),
                    new ClientAuthenticationHandler(embeddedServer));

                // 处理客户端的会话请求,包括SUBSCRIPTION,GET等
                SessionHandler sessionHandler = new SessionHandler(embeddedServer);
                pipelines.addLast(SessionHandler.class.getName(), sessionHandler);
                return pipelines;
            }
        });
    }
}
  • ClientAuthenticationHandler处理鉴权后,会移除HandshakeInitializationHandler和ClientAuthenticationHandler。最重要的是会话处理器SessionHandler。

6、获取Message数据/ACK流程

  • 以client发送GET,server从mysql得到binlog后,返回MESSAGES给client为例,说明client和server的rpc交互过程:
  • SimpleCanalConnector发送GET请求,并读取响应结果的流程:
public Message getWithoutAck(int batchSize, Long timeout, TimeUnit unit) throws CanalClientException {
    waitClientRunning();
    int size = (batchSize <= 0) ? 1000 : batchSize;
    long time = (timeout == null || timeout < 0) ? -1 : timeout; // -1代表不做timeout控制
    if (unit == null) unit = TimeUnit.MILLISECONDS;  //默认是毫秒

    // client发送GET请求
    writeWithHeader(Packet.newBuilder()
        .setType(PacketType.GET)
        .setBody(Get.newBuilder()
            .setAutoAck(false)
            .setDestination(clientIdentity.getDestination())
            .setClientId(String.valueOf(clientIdentity.getClientId()))
            .setFetchSize(size)
            .setTimeout(time)
            .setUnit(unit.ordinal())
            .build()
            .toByteString())
        .build()
        .toByteArray());
    // client获取GET结果    
    return receiveMessages();
}

private Message receiveMessages() throws IOException {
    // 读取server发送的数据包
    Packet p = Packet.parseFrom(readNextPacket());
    switch (p.getType()) {
        case MESSAGES: {
            Messages messages = Messages.parseFrom(p.getBody());
            Message result = new Message(messages.getBatchId());
            for (ByteString byteString : messages.getMessagesList()) {
                result.addEntry(Entry.parseFrom(byteString));
            }
            return result;
        }
    }
}
  • 服务端SessionHandler处理客户端发送的GET请求流程:
case GET:
    // 读取客户端发送的数据包,封装为Get对象
    Get get = CanalPacket.Get.parseFrom(packet.getBody());
    // destination表示canal instance
    if (StringUtils.isNotEmpty(get.getDestination()) && StringUtils.isNotEmpty(get.getClientId())) {
        clientIdentity = new ClientIdentity(get.getDestination(), Short.valueOf(get.getClientId()));
        Message message = null;
        if (get.getTimeout() == -1) {// 是否是初始值
            message = embeddedServer.getWithoutAck(clientIdentity, get.getFetchSize());
        } else {
            TimeUnit unit = convertTimeUnit(get.getUnit());
            message = embeddedServer.getWithoutAck(clientIdentity, get.getFetchSize(), get.getTimeout(), unit);
        }
        // 设置返回给客户端的数据包类型为MESSAGES   
        Packet.Builder packetBuilder = CanalPacket.Packet.newBuilder();
        packetBuilder.setType(PacketType.MESSAGES);
        // 构造Message
        Messages.Builder messageBuilder = CanalPacket.Messages.newBuilder();
        messageBuilder.setBatchId(message.getId());
        if (message.getId() != -1 && !CollectionUtils.isEmpty(message.getEntries())) {
            for (Entry entry : message.getEntries()) {
                messageBuilder.addMessages(entry.toByteString());
            }
        }
        packetBuilder.setBody(messageBuilder.build().toByteString());
        // 输出数据,返回给客户端
        NettyUtils.write(ctx.getChannel(), packetBuilder.build().toByteArray(), null);
    }

7、协议介绍

  • 具体的网络协议格式,可参见:CanalProtocol.proto
  • get/ack/rollback协议介绍:
  • Message getWithoutAck(int batchSize)
    • 允许指定batchSize,一次可以获取多条,每次返回的对象为Message,包含的内容为:
      • batch id 唯一标识
      • entries 具体的数据对象,对应的数据对象格式:EntryProtocol.proto
  • getWithoutAck(int batchSize, Long timeout, TimeUnit unit)
    • 相比于getWithoutAck(int batchSize),允许设定获取数据的timeout超时时间
      • 拿够batchSize条记录或者超过timeout时间
      • timeout=0,阻塞等到足够的batchSize
  • void rollback(long batchId)
    • 回滚上次的get请求,重新获取数据。基于get获取的batchId进行提交,避免误操作
  • void ack(long batchId)
    • 确认已经消费成功,通知server删除数据。基于get获取的batchId进行提交,避免误操作
  • EntryProtocol.protod对应的canal消息结构如下:
Entry  
    Header  
        logfileName [binlog文件名]  
        logfileOffset [binlog position]  
        executeTime [binlog里记录变更发生的时间戳,精确到秒]  
        schemaName   
        tableName  
        eventType [insert/update/delete类型]  
    entryType   [事务头BEGIN/事务尾END/数据ROWDATA]  
    storeValue  [byte数据,可展开,对应的类型为RowChange]  
      
RowChange  
    isDdl       [是否是ddl变更操作,比如create table/drop table]  
    sql         [具体的ddl sql]  
    rowDatas    [具体insert/update/delete的变更数据,可为多条,1个binlog event事件可对应多条变更,比如批处理]  
        beforeColumns [Column类型的数组,变更前的数据字段]  
        afterColumns [Column类型的数组,变更后的数据字段]  
          
Column   
    index         
    sqlType     [jdbc type]  
    name        [column name]  
    isKey       [是否为主键]  
    updated     [是否发生过变更]  
    isNull      [值是否为null]  
    value       [具体的内容,注意为string文本]

SessionHandler中服务端处理客户端的其他类型请求,都会调用CanalServerWithEmbedded的相关方法:

case SUBSCRIPTION:
        Sub sub = Sub.parseFrom(packet.getBody());
        embeddedServer.subscribe(clientIdentity);
case GET:
        Get get = CanalPacket.Get.parseFrom(packet.getBody());
        message = embeddedServer.getWithoutAck(clientIdentity, get.getFetchSize());
case CLIENTACK:
        ClientAck ack = CanalPacket.ClientAck.parseFrom(packet.getBody());
        embeddedServer.ack(clientIdentity, ack.getBatchId());
case CLIENTROLLBACK:
        ClientRollback rollback = CanalPacket.ClientRollback.parseFrom(packet.getBody());
        embeddedServer.rollback(clientIdentity);// 回滚所有批次
  • 所以真正的处理逻辑在CanalServerWithEmbedded中,下面重点来了。。。

CanalServerWithEmbedded

  • CanalServer包含多个Instance,它的成员变量canalInstances记录了instance名称与实例的映射关系。因为是一个Map,所以同一个Server不允许出现相同instance名称(本例中实例名称为example),比如不能同时有两个example在一个server上。但是允许一个Server上有example1和example2。

  • 注意:CanalServer中最重要的是CanalServerWithEmbedded,而CanalServerWithEmbedded中最重要的是CanalInstance。

public class CanalServerWithEmbedded extends AbstractCanalLifeCycle implements CanalServer, CanalService {
    private Map<String, CanalInstance> canalInstances;
    private CanalInstanceGenerator     canalInstanceGenerator;
}
  • 下图表示一个server配置了两个Canal实例(instance),每个Client连接一个Instance。每个Canal实例模拟为一个MySQL的slave,所以每个Instance的slaveId必须不一样。比如图中两个Instance的id分别是1234和1235,它们都会拉取MySQL主节点的binlog。在这里插入图片描述
  • 这里每个Canal Client都对应一个Instance,每个Client在启动时,都会指定一个Destination,这个Destination就表示Instance的名称。所以CanalServerWithEmbedded处理各种请求时的参数都有ClientIdentity,从ClientIdentity中获取destination,就可以获取出对应的CanalInstance。
  • 理解下各个组件的对应关系:
    • Canal Client通过destination找出Canal Server中对应的Canal Instance。
    • 一个Canal Server可以配置多个Canal Instances。
  • 下面以CanalServerWithEmbedded的订阅方法为例:
    • 根据客户端标识获取CanalInstance
    • 向CanalInstance的元数据管理器订阅当前客户端
    • 从元数据管理中获取客户端的游标
    • 通知CanalInstance订阅关系发生变化
  • 注意:提供订阅方法的作用是:MySQL新增了一张表,客户端原先没有同步这张表,现在需要同步,所以需要重新订阅。
public void subscribe(ClientIdentity clientIdentity) throws CanalServerException {
    // ClientIdentity表示Canal Client客户端,从中可以获取出客户端指定连接的Destination
    // 由于CanalServerWithEmbedded记录了每个Destination对应的Instance,可以获取客户端对应的Instance
    CanalInstance canalInstance = canalInstances.get(clientIdentity.getDestination());
    if (!canalInstance.getMetaManager().isStart()) {
        canalInstance.getMetaManager().start(); // 启动Instance的元数据管理器
    }
    canalInstance.getMetaManager().subscribe(clientIdentity); // 执行一下meta订阅
    Position position = canalInstance.getMetaManager().getCursor(clientIdentity);
    if (position == null) {
        position = canalInstance.getEventStore().getFirstPosition();// 获取一下store中的第一条
        if (position != null) {
            canalInstance.getMetaManager().updateCursor(clientIdentity, position); // 更新一下cursor
        }
    }
    // 通知下订阅关系变化
    canalInstance.subscribeChange(clientIdentity);
}

8、组件介绍

  • 每个CanalInstance中包括了四个组件:EventParser、EventSink、EventStore、MetaManager。
  • 服务端主要的处理方法包括get/ack/rollback,这三个方法都会用到Instance上面的几个内部组件,主要还是EventStore和MetaManager:
  • 在这之前,要先理解EventStore的含义,EventStore是一个RingBuffer,有三个指针:Put、Get、Ack。
    • Put: Canal Server从MySQL拉取到数据后,放到内存中,Put增加
    • Get: 消费者(Canal Client)从内存中消费数据,Get增加
    • Ack: 消费者消费完成,Ack增加。并且会删除Put中已经被Ack的数据
  • 这三个操作与Instance组件的关系如下:在这里插入图片描述
  • 客户端通过canal server获取mysql binlog有几种方式(get方法和getWithoutAck):
  • 如果timeout为null,则采用tryGet方式,即时获取
  • 如果timeout不为null
    • timeout为0,则采用get阻塞方式,获取数据,不设置超时,直到有足够的batchSize数据才返回
    • timeout不为0,则采用get+timeout方式,获取数据,超时还没有batchSize足够的数据,有多少返回多少
private Events<Event> getEvents(CanalEventStore eventStore, Position start, int batchSize, Long timeout,
                                TimeUnit unit) {
    if (timeout == null) {
        return eventStore.tryGet(start, batchSize); // 即时获取
    } else if (timeout <= 0){
        return eventStore.get(start, batchSize); // 阻塞获取
    } else {
        return eventStore.get(start, batchSize, timeout, unit); // 异步获取
    }
}
  • 注意:EventStore的实现采用了类似Disruptor的RingBuffer环形缓冲区。RingBuffer的实现类是MemoryEventStoreWithBuffer
  • get方法和getWithoutAck方法的区别是:
    • get方法会立即调用ack
    • getWithoutAck方法不会调用ack

EventStore

  • 以10条数据为例,初始时current=-1,第一个元素起始next=0,end=9,循环[0,9]所有元素。List元素为(A,B,C,D,E,F,G,H,I,J)
nextentries[next]next-current-1list element
0entries[0]0-(-1)-1=0A
1entries[1]1-(-1)-1=1B
2entries[2]2-(-1)-1=2C
3entries[3]3-(-1)-1=3D
  • 第一批10个元素put完成后,putSequence设置为end=9。假设第二批又Put了5个元素:(K,L,M,N,O)
    current=9,起始next=9+1=10,end=9+5=14,在Put完成后,putSequence设置为end=14。
nextentries[next]next-current-1list element
10entries[10]10-(9)-1=0K
11entries[11]11-(9)-1=1L
12entries[12]12-(9)-1=2M
13entries[13]13-(9)-1=3N
14entries[14]14-(9)-1=3O
  • 这里假设环形缓冲区的最大大小为15个(源码中是16MB),那么上面两批一共产生了15个元素,刚好填满了环形缓冲区。如果又有Put事件进来,由于环形缓冲区已经满了,没有可用的slot,则Put操作会被阻塞,直到被消费掉。
  • 下面是Put填充环形缓冲区的代码,检查可用slot(checkFreeSlotAt方法)在几个put方法中。
public class MemoryEventStoreWithBuffer extends AbstractCanalStoreScavenge implements CanalEventStore<Event>, CanalStoreScavenge {
    private static final long INIT_SQEUENCE = -1;
    private int               bufferSize    = 16 * 1024;
    private int               bufferMemUnit = 1024;                         // memsize的单位,默认为1kb大小
    private int               indexMask;
    private Event[]           entries;

    // 记录下put/get/ack操作的三个下标
    private AtomicLong        putSequence   = new AtomicLong(INIT_SQEUENCE); // 代表当前put操作最后一次写操作发生的位置
    private AtomicLong        getSequence   = new AtomicLong(INIT_SQEUENCE); // 代表当前get操作读取的最后一条的位置
    private AtomicLong        ackSequence   = new AtomicLong(INIT_SQEUENCE); // 代表当前ack操作的最后一条的位置

    // 启动EventStore时,创建指定大小的缓冲区,Event数组的大小是16*1024
    // 也就是说算个数的话,数组可以容纳16000个事件。算内存的话,大小为16MB
    public void start() throws CanalStoreException {
        super.start();
        indexMask = bufferSize - 1;
        entries = new Event[bufferSize];
    }

    // EventParser解析后,会放入内存中(Event数组,缓冲区)
    private void doPut(List<Event> data) {
        long current = putSequence.get(); // 取得当前的位置,初始时为-1,第一个元素为-1+1=0
        long end = current + data.size(); // 最末尾的位置,假设Put了10条数据,end=-1+10=9
        // 先写数据,再更新对应的cursor,并发度高的情况,putSequence会被get请求可见,拿出了ringbuffer中的老的Entry值
        for (long next = current + 1; next <= end; next++) {
            entries[getIndex(next)] = data.get((int) (next - current - 1));
        }
        putSequence.set(end);
    } 
}
  • Put是生产数据,Get是消费数据,Get一定不会超过Put。比如Put了10条数据,Get最多只能获取到10条数据。但有时候为了保证Get处理的速度,Put和Get并不会相等。可以把Put看做是生产者,Get看做是消费者。生产者速度可以很快,消费者则可以慢慢地消费。比如Put了1000条,而Get我们只需要每次处理10条数据。
  • 仍然以前面的示例来说明Get的流程,初始时current=-1,假设Put了两批数据一共15条,maxAbleSequence=14,而Get的BatchSize假设为10。初始时next=current=-1,end=-1。通过startPosition,会设置next=0。最后end又被赋值为9,即循环缓冲区[0,9]一共10个元素。
private Events<Event> doGet(Position start, int batchSize) throws CanalStoreException {
    LogPosition startPosition = (LogPosition) start;

    long current = getSequence.get();
    long maxAbleSequence = putSequence.get();
    long next = current;
    long end = current;
    // 如果startPosition为null,说明是第一次,默认+1处理
    if (startPosition == null || !startPosition.getPostion().isIncluded()) { // 第一次订阅之后,需要包含一下start位置,防止丢失第一条记录
        next = next + 1;
    }

    end = (next + batchSize - 1) < maxAbleSequence ? (next + batchSize - 1) : maxAbleSequence;
    // 提取数据并返回
    for (; next <= end; next++) {
        Event event = entries[getIndex(next)];
        if (ddlIsolation && isDdl(event.getEntry().getHeader().getEventType())) {
            // 如果是ddl隔离,直接返回
            if (entrys.size() == 0) {
                entrys.add(event);// 如果没有DML事件,加入当前的DDL事件
                end = next; // 更新end为当前
            } else {
                // 如果之前已经有DML事件,直接返回了,因为不包含当前next这记录,需要回退一个位置
                end = next - 1; // next-1一定大于current,不需要判断
            }
            break;
        } else {
            entrys.add(event);
        }
    }
    // 处理PositionRange,然后设置getSequence为end
    getSequence.compareAndSet(current, end)
}
  • ack操作的上限是Get,假设Put了15条数据,Get了10条数据,最多也只能Ack10条数据。Ack的目的是清空缓冲区中已经被Get过的数据
public void ack(Position position) throws CanalStoreException {
    cleanUntil(position);
}

public void cleanUntil(Position position) throws CanalStoreException {
    long sequence = ackSequence.get();
    long maxSequence = getSequence.get();

    boolean hasMatch = false;
    long memsize = 0;
    for (long next = sequence + 1; next <= maxSequence; next++) {
        Event event = entries[getIndex(next)];
        memsize += calculateSize(event);
        boolean match = CanalEventUtils.checkPosition(event, (LogPosition) position);
        if (match) {// 找到对应的position,更新ack seq
            hasMatch = true;

            if (batchMode.isMemSize()) {
                ackMemSize.addAndGet(memsize);
                // 尝试清空buffer中的内存,将ack之前的内存全部释放掉
                for (long index = sequence + 1; index < next; index++) {
                    entries[getIndex(index)] = null;// 设置为null
                }
            }

            ackSequence.compareAndSet(sequence, next)
        }
    }
}
  • rollback回滚方法的实现则比较简单,将getSequence回退到ack位置。
public void rollback() throws CanalStoreException {
    getSequence.set(ackSequence.get());
    getMemSize.set(ackMemSize.get());
}
  • 下图展示了RingBuffer的几个操作示例:在这里插入图片描述

EventParser WorkFlow

  • EventStore负责存储解析后的Binlog事件,而解析动作负责拉取Binlog,它的流程比较复杂。需要和MetaManager进行交互。比如要记录每次拉取的Position,这样下一次就可以从上一次的最后一个位置继续拉取。所以MetaManager应该是有状态的。
  • EventParser的流程如下:
    • Connection获取上一次解析成功的位置 (如果第一次启动,则获取初始指定的位置或者是当前数据库的binlog位点)
    • Connection建立链接,发送BINLOG_DUMP指令
    • Mysql开始推送Binaly Log
    • 接收到的Binaly Log的通过Binlog parser进行协议解析,补充一些特定信息
    • 传递给EventSink模块进行数据存储,是一个阻塞操作,直到存储成功
    • 存储成功后,定时记录Binaly Log位置在这里插入图片描述
  • 0
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值