MySQL 的 Debezium 连接器-中文版

MySQL 的 Debezium 连接器

MySQL 有一个二进制日志(binlog),它按照提交到数据库的顺序记录所有操作。这包括对表模式的更改以及对表中数据的更改。MySQL 使用 binlog 进行复制和恢复。

Debezium MySQL 连接器读取 binlog,为行级INSERT、、UPDATEDELETE操作生成更改事件,并将更改事件发送到 Kafka 主题。客户端应用程序读取这些 Kafka 主题。

由于 MySQL 通常设置为在指定时间段后清除 binlog,因此 MySQL 连接器会对您的每个数据库执行初始一致快照。MySQL 连接器从创建快照的位置读取 binlog。

有关与此连接器兼容的 MySQL 数据库版本的信息,请参阅Debezium 版本概述

连接器的工作原理

连接器支持的 MySQL 拓扑的概述对于规划您的应用程序很有用。为了优化配置和运行 Debezium MySQL 连接器,了解连接器如何跟踪表结构、公开模式更改、执行快照以及确定 Kafka 主题名称会很有帮助。

Debezium MySQL 连接器尚未在 MariaDB 上进行测试,但来自社区的多份报告表明该连接器已成功用于该数据库。计划在未来的 Debezium 版本中提供对 MariaDB 的官方支持。

支持的 MySQL 拓扑

Debezium MySQL 连接器支持以下 MySQL 拓扑:

  • 独立

    当使用单个 MySQL 服务器时,服务器必须启用 binlog(并且可选地启用 GTID),以便 Debezium MySQL 连接器可以监控服务器。这通常是可以接受的,因为二进制日志也可以用作增量备份。在这种情况下,MySQL 连接器始终连接并跟随这个独立的 MySQL 服务器实例。

  • 主副本和副本

    Debezium MySQL 连接器可以跟随主服务器之一或副本之一(如果该副本启用了其 binlog),但连接器仅看到该服务器可见的集群中的更改。通常,除了多主拓扑之外,这不是问题。连接器将其位置记录在服务器的 binlog 中,这在集群中的每台服务器上都是不同的。因此,连接器必须只跟随一个 MySQL 服务器实例。如果该服务器出现故障,则必须重新启动或恢复该服务器,然后连接器才能继续。

  • 高可用集群

    MySQL 存在多种高可用性解决方案,它们使容忍问题和故障并几乎立即从问题和故障中恢复变得更加容易。大多数 HA MySQL 集群使用 GTID,以便副本能够跟踪任何主服务器上的所有更改。

  • 多主

    网络数据库 (NDB) 集群复制使用一个或多个 MySQL 副本节点,每个节点从多个主服务器复制。这是聚合多个 MySQL 集群的复制的强大方法。此拓扑需要使用 GTID。Debezium MySQL 连接器可以使用这些多主 MySQL 副本作为源,并且只要新副本赶上旧副本,就可以故障转移到不同的多主 MySQL 副本。也就是说,新副本具有在第一个副本上看到的所有事务。即使连接器仅使用数据库和/或表的一个子集,这也有效,因为可以将连接器配置为在尝试重新连接到新的多主 MySQL 副本并在二进制日志。

  • 托管

    支持 Debezium MySQL 连接器以使用托管选项,例如 Amazon RDS 和 Amazon Aurora。因为这些托管选项不允许全局读锁,所以使用表级锁来创建一致快照

架构历史主题

当数据库客户端查询数据库时,客户端使用数据库的当前模式。但是,数据库架构可以随时更改,这意味着连接器必须能够识别每次插入、更新或删除操作被记录时的架构。此外,连接器不能只使用当前模式,因为连接器可能正在处理在更改表模式之前记录的相对较旧的事件。

为了确保正确处理架构更改后发生的更改,MySQL 在 binlog 中不仅包括对数据的行级更改,还包括应用于数据库的 DDL 语句。当连接器读取 binlog 并遇到这些 DDL 语句时,它会解析它们并更新每个表模式的内存表示。连接器使用此模式表示来识别每次插入、更新或删除操作时的表结构,并产生适当的更改事件。在单独的数据库历史 Kafka 主题中,连接器记录所有 DDL 语句以及每个 DDL 语句出现在 binlog 中的位置。

当连接器在崩溃或优雅停止后重新启动时,连接器会从特定位置,即从特定时间点开始读取 binlog。连接器通过读取数据库历史 Kafka 主题并解析所有 DDL 语句,直到连接器启动的二进制日志中的点,来重建此时存在的表结构。

此数据库历史主题仅供连接器使用。连接器可以选择将模式更改事件发送到针对消费者应用程序的不同主题

当 MySQL 连接器捕获应用了架构更改工具(例如gh-ost或)的表中的更改pt-online-schema-change时,会在迁移过程中创建辅助表。需要配置连接器以捕获对这些帮助表的更改。如果消费者不需要为帮助表生成的记录,则可以应用单个消息转换将它们过滤掉。

查看接收 Debezium 事件记录的主题的默认名称。

架构更改主题

您可以配置 Debezium MySQL 连接器以生成模式更改事件,这些事件描述应用于数据库中捕获的表的模式更改。连接器将架构更改事件写入名为 的 Kafka 主题*<serverName>*,其中是连接器配置属性*serverName*中指定的逻辑服务器名称。database.server.name连接器发送到架构更改主题的消息包含有效负载,并且(可选)还包含更改事件消息的架构。

架构更改事件消息的有效负载包括以下元素:

  • ddl

    提供导致架构更改的 SQL CREATEALTER或语句。DROP

  • databaseName

    应用 DDL 语句的数据库的名称。的值databaseName用作消息键。

  • pos

    语句出现在 binlog 中的位置。

  • tableChanges

    架构更改后整个表架构的结构化表示。该tableChanges字段包含一个数组,其中包含表中每一列的条目。由于结构化表示以 JSON 或 Avro 格式呈现数据,因此消费者可以轻松读取消息,而无需先通过 DDL 解析器对其进行处理。

对于处于捕获模式的表,连接器不仅将模式更改的历史记录存储在模式更改主题中,还会存储在内部数据库历史记录主题中。内部数据库历史主题仅供连接器使用,不适合消费应用程序直接使用。确保需要有关架构更改通知的应用程序仅使用来自架构更改主题的信息。
永远不要对数据库历史主题进行分区。要使数据库历史主题正确运行,它必须保持连接器向其发出的事件记录的一致的全局顺序。为确保主题不会在分区之间拆分,请使用以下方法之一设置主题的分区计数:如果您手动创建数据库历史主题,请将分区计数指定为1.如果您使用 Apache Kafka 代理自动创建数据库历史主题,则会创建主题,请将Kafkanum.partitions配置选项的值设置为1.
连接器向其模式更改主题发出的消息格式处于孵化状态,如有更改,恕不另行通知。

示例:发送到 MySQL 连接器架构更改主题的消息

以下示例显示了 JSON 格式的典型架构更改消息。该消息包含表模式的逻辑表示。

{
   
  "schema": {
   
  ...
  },
  "payload": {
   
        "source": {
     // (1)
        "version": "1.9.5.Final",
        "connector": "mysql",
        "name": "dbserver1",
        "ts_ms": 0,
        "snapshot": "false",
        "db": "inventory",
        "sequence": null,
        "table": "customers",
        "server_id": 0,
        "gtid": null,
        "file": "mysql-bin.000003",
        "pos": 219,
        "row": 0,
        "thread": null,
        "query": null
    },
    "databaseName": "inventory", // (2)
    "schemaName": null,
    "ddl": "ALTER TABLE customers ADD COLUMN middle_name VARCHAR(2000)", // (3)
    "tableChanges": [ // (4)
        {
   
        "type": "ALTER", // (5)
        "id": "\"inventory\".\"customers\"",  // (6)
        "table": {
    // (7)
            "defaultCharsetName": "latin1",
            "primaryKeyColumnNames": [  // (8)
                "id"
            ],
            "columns": [ // (9)
                {
   
                "name": "id",
                "jdbcType": 4,
                "nativeType": null,
                "typeName": "INT",
                "typeExpression": "INT",
                "charsetName": null,
                "length": 11,
                "scale": null,
                "position": 1,
                "optional": false,
                "autoIncremented": true,
                "generated": true
            },
            {
   
                "name": "first_name",
                "jdbcType": 12,
                "nativeType": null,
                "typeName": "VARCHAR",
                "typeExpression": "VARCHAR",
                "charsetName": "latin1",
                "length": 255,
                "scale": null,
                "position": 2,
                "optional": false,
                "autoIncremented": false,
                "generated": false
            },                        {
   
                "name": "last_name",
                "jdbcType": 12,
                "nativeType": null,
                "typeName": "VARCHAR",
                "typeExpression": "VARCHAR",
                "charsetName": "latin1",
                "length": 255,
                "scale": null,
                "position": 3,
                "optional": false,
                "autoIncremented": false,
                "generated": false
            },
            {
   
                "name": "email",
                "jdbcType": 12,
                "nativeType": null,
                "typeName": "VARCHAR",
                "typeExpression": "VARCHAR",
                "charsetName": "latin1",
                "length": 255,
                "scale": null,
                "position": 4,
                "optional": false,
                "autoIncremented": false,
                "generated": false
            },
            {
   
                "name": "middle_name",
                "jdbcType": 12,
                "nativeType": null,
                "typeName": "VARCHAR",
                "typeExpression": "VARCHAR",
                "charsetName": "latin1",
                "length": 2000,
                "scale": null,
                "position": 5,
                "optional": true,
                "autoIncremented": false,
                "generated": false
            }
          ]
        }
      }
    ]
  },
  "payload": {
   
    "databaseName": "inventory",
    "ddl": "CREATE TABLE products ( id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, description VARCHAR(512), weight FLOAT ); ALTER TABLE products AUTO_INCREMENT = 101;",
    "source" : {
   
      "version": "1.9.5.Final",
      "name": "mysql-server-1",
      "server_id": 0,
      "ts_ms": 0,
      "gtid": null,
      "file": "mysql-bin.000003",
      "pos": 154,
      "row": 0,
      "snapshot": true,
      "thread": null,
      "db": null,
      "table": null,
      "query": null
    }
  }
}
物品 字段名称 描述
1 source source字段的结构与连接器写入特定于表的主题的标准数据更改事件完全相同。此字段可用于关联不同主题的事件。
2 databaseName schemaName 标识包含更改的数据库和架构。该databaseName字段的值用作记录的消息键。
3 ddl 此字段包含负责架构更改的 DDL。该ddl字段可以包含多个 DDL 语句。每个语句都适用于数据库中的databaseName字段。多个 DDL 语句按照它们应用于数据库的顺序出现。 客户端可以提交多个适用于多个数据库的 DDL 语句。如果 MySQL 以原子方式应用它们,则连接器按顺序获取 DDL 语句,按数据库对它们进行分组,并为每个组创建一个模式更改事件。如果 MySQL 单独应用它们,连接器会为每个语句创建一个单独的模式更改事件。
4 tableChanges 包含由 DDL 命令生成的架构更改的一个或多个项目的数组。
5 type 描述变化的种类。该值为以下之一:CREATE表已创建。ALTER表已修改。DROP表已删除。
6 id 创建、更改或删除的表的完整标识符。在表重命名的情况下,此标识符是表名的串联。*<old>*,*<new>*
7 table 表示应用更改后的表元数据。
8 primaryKeyColumnNames 组成表的主键的列的列表。
9 columns 已更改表中每一列的元数据。

另请参阅:模式历史主题

快照

首次启动 Debezium MySQL 连接器时,它会执行数据库的初始一致快照。以下流程描述了连接器如何创建此快照。此流程适用于默认快照模式,即initial. 有关其他快照模式的信息,请参阅MySQL 连接器snapshot.mode配置属性

行动
1 获取阻止其他数据库客户端写入的全局读锁。 快照本身不会阻止其他客户端应用可能会干扰连接器尝试读取 binlog 位置和表模式的 DDL。连接器在读取 binlog 位置时保持全局读锁,并如后面的步骤所述释放锁。
2 启动具有可重复读取语义的事务,以确保事务中的所有后续读取都针对一致的快照完成。
3 读取当前的 binlog 位置。
4 读取连接器配置为捕获更改的数据库和表的架构。
5 释放全局读锁。其他数据库客户端现在可以写入数据库。
6 如果适用,将 DDL 更改写入架构更改主题,包括所有必要DROP…CREATE…DDL 语句。
7 扫描数据库表。对于每一行,连接器将CREATE事件发送到相关的特定于表的 Kafka 主题。
8 提交事务。
9 在连接器偏移中记录完成的快照。
  • 连接器重新启动

    如果连接器在执行初始快照时发生故障、停止或重新平衡,则在连接器重新启动后,它会执行新的快照。在初始快照完成后,Debezium MySQL 连接器从 binlog 中的相同位置重新启动,因此它不会错过任何更新。如果连接器停止的时间足够长,MySQL 可能会清除旧的二进制日志文件,连接器的位置就会丢失。如果位置丢失,连接器将恢复为其起始位置的*初始快照。*有关对 Debezium MySQL 连接器进行故障排除的更多提示,请参阅出现问题时的行为

  • 不允许全局读锁

    某些环境不允许全局读锁。如果 Debezium MySQL 连接器检测到不允许全局读锁,则连接器使用表级锁代替并使用此方法执行快照。这要求 Debezium 连接器的数据库用户具有LOCK TABLES权限。表 3. 使用表级锁执行初始快照的工作流程步行动1获取表级锁。2启动具有可重复读取语义的事务,以确保事务中的所有后续读取都针对一致的快照完成。3读取和过滤数据库和表的名称。4读取当前的 binlog 位置。5读取连接器配置为捕获更改的数据库和表的架构。6如果适用,将 DDL 更改写入架构更改主题,包括所有必要DROP…CREATE…DDL 语句。7扫描数据库表。对于每一行,连接器将CREATE事件发送到相关的特定于表的 Kafka 主题。8提交事务。9释放表级锁。10在连接器偏移中记录完成的快照。

即席快照

默认情况下,连接器仅在首次启动后才运行初始快照操作。在这个初始快照之后,在正常情况下,连接器不会重复快照过程。连接器捕获的任何未来更改事件数据仅通过流式处理进入。

但是,在某些情况下,连接器在初始快照期间获得的数据可能会变得陈旧、丢失或不完整。为了提供一种重新捕获表数据的机制,Debezium 包含一个执行临时快照的选项。数据库中的以下更改可能会导致执行临时快照:

  • 修改连接器配置以捕获一组不同的表。
  • Kafka 主题被删除,必须重建。
  • 由于配置错误或其他问题而发生数据损坏。

您可以通过启动所谓的ad-hoc 快照为之前捕获快照的表重新运行快照。即席快照需要使用信令表。您可以通过向 Debezium 信号表发送信号请求来启动临时快照。

当您启动现有表的临时快照时,连接器会将内容附加到表已存在的主题中。如果删除了以前存在的主题,如果启用了自动主题创建,Debezium 可以自动创建主题。

即席快照信号指定要包含在快照中的表。快照可以捕获数据库的全部内容,或仅捕获数据库中表的子集。

execute-snapshot您可以通过向信令表发送消息来指定要捕获的表。将execute-snapshot信号的类型设置为incremental,并提供要包含在快照中的表的名称,如下表所述:

场地 默认 价值
type incremental 指定要运行的快照类型。 设置类型是可选的。目前,您只能请求incremental快照。
data-collections 不适用 一个数组,其中包含要生成快照的表的完全限定名称。名称的格式与配置选项 的格式相同。signal.data.collection

触发临时快照

execute-snapshot您可以通过将具有信号类型的条目添加到信令表来启动临时快照。连接器处理完消息后,将开始快照操作。快照进程读取第一个和最后一个主键值,并将这些值用作每个表的起点和终点。根据表中的条目数和配置的块大小,Debezium 将表划分为块,并继续对每个块进行快照,一次一个。

目前,execute-snapshot操作类型仅触发增量快照。有关详细信息,请参阅增量快照

增量快照

为了提供管理快照的灵活性,Debezium 包含一个补充快照机制,称为增量快照。增量快照依靠 Debezium 机制向 Debezium 连接器发送信号。增量快照基于DDD-3设计文档。

在增量快照中,Debezium 不是像在初始快照中那样一次捕获数据库的完整状态,而是在一系列可配置的块中分阶段捕获每个表。您可以指定您希望快照捕获的表和每个块的大小。块大小决定了快照在数据库上的每次提取操作期间收集的行数。增量快照的默认块大小为 1 KB。

随着增量快照的进行,Debezium 使用水印来跟踪其进度,维护它捕获的每个表行的记录。与标准初始快照过程相比,这种分阶段捕获数据的方法具有以下优势:

  • 您可以在流式数据捕获的同时运行增量快照,而不是将流式传输推迟到快照完成。连接器在整个快照过程中继续从更改日志中捕获近乎实时的事件,并且两个操作都不会阻塞另一个操作。
  • 如果增量快照的进度中断,您可以恢复它而不会丢失任何数据。进程恢复后,快照从它停止的点开始,而不是从头重新捕获表。
  • 您可以随时按需运行增量快照,并根据需要重复该过程以适应数据库更新。例如,您可以在修改连接器配置以将表添加到其table.include.list属性后重新运行快照。

增量快照过程

当您运行增量快照时,Debezium 按主键对每个表进行排序,然后根据配置的块大小将表拆分为块。逐块工作,然后捕获块中的每个表行。对于它捕获的每一行,快照都会发出一个READ事件。该事件表示块的快照开始时行的值。

随着快照的进行,其他进程可能会继续访问数据库,可能会修改表记录。为反映此类更改,INSERTUPDATEDELETE操作将照常提交到事务日志。同样,正在进行的 Debezium 流式处理继续检测这些更改事件并将相应的更改事件记录发送到 Kafka。

Debezium 如何解决具有相同主键的记录之间的冲突

在某些情况下,流式处理发出的UPDATEDELETE事件被乱序接收。也就是说,流式处理可能会在快照捕获包含该行的READ事件的块之前发出一个修改表行的事件。当快照最终为该行发出相应的READ事件时,它的值已经被取代。为了确保以正确的逻辑顺序处理乱序到达的增量快照事件,Debezium 采用了一种缓冲方案来解决冲突。只有在解决了快照事件和流事件之间的冲突后,Debezium 才会向 Kafka 发出事件记录。

快照窗口

为了帮助解决延迟到达事件和修改同一表行的流事件之间的冲突READ,Debezium 采用了所谓的快照窗口。快照窗口划分了增量快照捕获指定表块数据的时间间隔。在一个块的快照窗口打开之前,Debezium 遵循其通常的行为并从事务日志直接向下游发送事件到目标 Kafka 主题。但是从特定块的快照打开的那一刻起,直到它关闭,Debezium 执行重复数据删除步骤以解决具有相同主键的事件之间的冲突。

对于每个数据集合,Debezium 发出两种类型的事件,并将它们的记录存储在单个目标 Kafka 主题中。它直接从表中捕获的快照记录作为READ操作发出。同时,随着用户不断更新数据集合中的记录,事务日志也更新以反映每次提交,Debezium 会针对每次更改发出UPDATE或操作。DELETE

当快照窗口打开时,Debezium 开始处理快照块,它将快照记录传递到内存缓冲区。在快照窗口期间,READ缓冲区中事件的主键与传入流事件的主键进行比较。如果未找到匹配项,则将流式事件记录直接发送到 Kafka。如果 Debezium 检测到匹配,它会丢弃缓冲的READ事件,并将流式记录写入目标主题,因为流式事件在逻辑上取代了静态快照事件。块的快照窗口关闭后,缓冲区仅包含READ不存在相关事务日志事件的事件。Debezium 将这些剩余READ事件发送到表的 Kafka 主题。

连接器对每个快照块重复该过程。

触发增量快照

目前,启动增量快照的唯一方法是将临时快照信号发送到源数据库上的信令表。INSERT您将信号作为 SQL查询提交给表。Debezium 检测到信号表中的变化后,它会读取信号,并运行请求的快照操作。

您提交的查询指定要包含在快照中的表,并且可以选择指定快照操作的类型。目前,快照操作的唯一有效选项是默认值incremental.

要指定要包含在快照中的表,请提供一个data-collections列出这些表的数组,例如,
{"data-collections": ["public.MyFirstTable", "public.MySecondTable"]}

增量快照信号的data-collections数组没有默认值。如果data-collections数组为空,Debezium 检测到不需要任何操作并且不执行快照。

如果要包含在快照中的表.的名称在数据库、模式或表的名称中包含点 (),则要将表添加到data-collections数组中,您必须用双引号对名称的每个部分进行转义. 例如,要包含存在于**public**架构中且名称为 的表**My.Table**,请使用以下格式:**"public"."My.Table"**.

先决条件

程序

  1. 发送 SQL 查询以将临时增量快照请求添加到信令表:

    INSERT INTO _<signalTable>_ (id, type, data) VALUES (_'<id>'_, _'<snapshotType>'_, '{"data-collections": ["_<tableName>_","_<tableName>_"],"type":"_<snapshotType>_"}');
    

    例如,

    INSERT INTO myschema.debezium_signal (id, type, data) VALUES('ad-hoc-1', 'execute-snapshot', '{"data-collections": ["schema1.table1", "schema2.table2"],"type":"incremental"}');
    

    id命令中的、type和参数的值data对应于

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
以下是使用Debezium自定义MariaDB连接器的完整代码示例: ``` package com.example.debezium; import io.debezium.connector.mysql.MySqlConnectorConfig; import io.debezium.connector.mysql.MySqlConnectorTask; import io.debezium.embedded.EmbeddedEngine; import io.debezium.engine.DebeziumEngine; import io.debezium.engine.format.Json; import io.debezium.relational.history.FileDatabaseHistory; import org.apache.kafka.connect.json.JsonConverter; import org.apache.kafka.connect.storage.StringConverter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class CustomMariaDBConnector { private static final Logger LOGGER = LoggerFactory.getLogger(CustomMariaDBConnector.class); public static void main(String[] args) throws IOException { final String kafkaBootstrapServers = "localhost:9092"; final String databaseHostName = "localhost"; final int databasePort = 3306; final String databaseUser = "root"; final String databasePassword = "password"; final String databaseServerName = "my-server"; final String databaseHistoryPath = "/path/to/database/history/file"; final Map<String, String> config = new HashMap<>(); config.put("name", databaseServerName); config.put("connector.class", "io.debezium.connector.mysql.MySqlConnector"); config.put("database.hostname", databaseHostName); config.put("database.port", String.valueOf(databasePort)); config.put("database.user", databaseUser); config.put("database.password", databasePassword); config.put("database.server.id", "184054"); config.put("database.server.name", databaseServerName); config.put("database.history", "io.debezium.relational.history.FileDatabaseHistory"); config.put("database.history.file.filename", databaseHistoryPath); config.put("table.whitelist", "mydb.mytable"); final MySqlConnectorConfig connectorConfig = new MySqlConnectorConfig(config); final MySqlConnectorTask task = new MySqlConnectorTask(connectorConfig); final JsonConverter keyConverter = new JsonConverter(); final JsonConverter valueConverter = new JsonConverter(); final StringConverter headerConverter = new StringConverter(); keyConverter.configure(config, true); valueConverter.configure(config, false); headerConverter.configure(config, true); final ExecutorService executor = Executors.newSingleThreadExecutor(); final DebeziumEngine<ChangeEvent<String, String>> engine = EmbeddedEngine.create() .using(task) .using(keyConverter) .using(valueConverter) .using(headerConverter) .notifying(record -> LOGGER.info("Record: {}", record)) .using(Json::newBuilder) .using(new FileDatabaseHistory()) .build(); Runtime.getRuntime().addShutdownHook(new Thread(() -> { try { LOGGER.info("Stopping Debezium engine"); engine.stop(); executor.shutdown(); executor.awaitTermination(10, TimeUnit.SECONDS); } catch (InterruptedException e) { LOGGER.error("Error while stopping Debezium engine", e); } LOGGER.info("Debezium engine stopped"); })); LOGGER.info("Starting Debezium engine"); executor.execute(engine); } } ``` 请注意,这个示例是使用Debezium的EmbeddedEngine创建的,但是你也可以使用Debezium的StandaloneEngine。此外,你需要将以下依赖项添加到你的项目中: - debezium-connector-mysql - debezium-embedded - kafka-clients - kafka-connect-api - kafka-streams - slf4j-api 你还需要在你的Kafka集群中创建一个名为“my-server”的主题。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值