1. 为什么元数据同步成了数据湖的头号痛点
做过数据湖仓一体的人都有一个共同感受:数据写入本身不难,难的是让上下游所有组件对“数据长什么样”这件事始终保持一致。Hudi 作为数据湖里最常被拿来处理增量更新的方案,它的元数据体系本身就比普通文件表复杂得多——有表的 schema、分区信息、提交时间线、文件组、索引映射,还有各种 commit 和 compaction 的状态。当这些表通过 Flink 实时写入时,元数据变化是随时发生的:新分区冒出来、字段被追加、小文件被合并、commit 不断推进。如果下游的查询引擎、调度系统、数据质量平台拿到的还是旧元数据,那结果就是查询报错、数据漏读、任务失败。
传统做法是定时跑一个同步任务,每隔几分钟去拉一次 Hudi 的元数据,然后推给下游。这个方案在离线场景下勉强能用,但在实时链路里问题很大:延迟高、空跑多、元数据变化和同步之间有明显的时间窗口,窗口期内下游看到的都是过期信息。更麻烦的是,Hudi 的元数据变化并不是均匀发生的,有时候几分钟没动静,有时候一秒内好几个 commit 连着来,定时轮询要么漏要么重。
“Hudi on Flink 动态同步元数据变化”要解决的就是这个问题:把元数据同步从“定时拉”变成“事件驱动推”,让 Flink 在写入 Hudi 的过程中,顺带把元数据变化实时广播出去。这样下游拿到的元数据几乎和写入端同步,延迟从分钟级压到秒级甚至更低。这套方法适合正在做实时湖仓、有 Flink 实时入湖链路、并且下游有多个消费方的团队参考,对元数据一致性要求越高,收益越明显。
2. 整体设计思路与方案选型拆解
2.1 为什么不用定时轮询而选事件驱动
先说清楚定时轮询到底差在哪。假设同步周期是 5 分钟,Hudi 表在这 5 分钟内发生了 3 次 commit、新增了 2 个分区、schema 追加了 1 个字段。轮询任务在第 5 分钟才把这些变化一次性拉走,那么前 4 分多钟里,下游看到的元数据全是旧的。如果下游是一个实时查询服务,用户在这段时间查新分区就会报“分区不存在”;如果下游是数据质量平台,它可能对着一张还没更新的 schema 做校验,直接误判。
事件驱动的思路是把“元数据变了”这件事本身当成一个事件。Flink 在写 Hudi 的时候,本来就知道自己做了什么:什么时候开始一个 commit、什么时候写完、写了哪些分区、schema 有没有变。这些信息在写入端是天然存在的,不需要额外去“发现”。我们要做的就是在这些关键节点上挂一个回调或者拦截器,把变化封装成消息发出去。下游订阅这个消息流,收到就更新本地元数据缓存。
这个方案的优势很直接:延迟取决于消息投递速度,通常几十毫秒到几秒;没有空跑,没变化就不发;不会漏,因为每个 commit 都会触发;也不会重,因为可以带 commit 唯一标识做幂等。代价是需要在写入链路里埋点,对写入代码有一定侵入性,但相比收益,这个代价是值得的。
2.2 元数据变化的分类与捕获时机
不是所有元数据变化都值得同步,也不是所有变化都能在同一个时机捕获。我一般把 Hudi 元数据变化分成三类,分别对应不同的捕获点。
第一类是表结构变化,包括字段新增、字段类型变更、字段注释修改。这类变化发生在写入前的 schema 解析阶段,Flink 拿到上游数据后会和 Hudi 表的现有 schema 做比对,发现不一致就会触发 schema evolution。捕获点就在 schema 比对完成、evolution 执行成功之后。
第二类是分区变化,包括新分区创建、分区下文件组变化。这类变化发生在写入过程中,Flink 的 writer 在分配文件组时会创建新分区目录。捕获点可以在分区创建成功后,也可以在 commit 成功后统一收集。
第三类是提交变化,包括 commit、compaction、clean 的完成。这类变化是表级别的状态推进,捕获点最自然的就是 commit 回调。Hudi 的 Flink writer 在 commit 成功后会触发commitCallback,我们可以在这里把本次 commit 涉及的元数据变化打包发出去。
注意:不要在 commit 之前发消息。如果 commit 最终失败了,下游却已经收到“元数据变了”的通知,就会造成元数据和实际数据不一致。所有同步动作必须放在 commit 成功之后。
2.3 消息通道的选型考量
元数据变化消息发到哪里,这个选择直接影响方案的通用性。常见的有三种:消息队列、Flink 侧输出流、以及直接写元数据存储。
消息队列(比如常见的分布式消息中间件)是最通用的,解耦彻底,下游可以是任何能消费消息的系统,而且天然支持多订阅方。缺点是引入了一个外部依赖,运维成本增加,消息投递本身也有延迟。
Flink 侧输出流(side output)适合下游本身就是 Flink 任务的场景,不需要外部组件,延迟最低。但它的消费方被限制在同一个 Flink 作业或者能拿到这个流的作业里,通用性差一些。
直接写元数据存储(比如把变化写到一张 Hudi 元数据表或者外部数据库)适合下游是查询引擎的场景,查询引擎直接读这张表就行。缺点是写入端要维护额外的存储连接,而且多订阅方还是得各自去读。
我实际用下来,消息队列 + 侧输出流双写是比较稳的组合:侧输出流给同作业内的下游用,消息队列给外部系统用。如果只能选一个,优先消息队列,因为解耦带来的灵活性在长期维护中价值更大。
3. 核心细节解析与实操要点
3.1 Hudi Flink Writer 的扩展点在哪里
Hudi 的 Flink writer 在StreamWriteOperatorCoordinator里管理 commit 生命周期,在StreamWriteFunction里做实际写入。要挂载元数据同步逻辑,主要有两个扩展点。
第一个是StreamWriteFunction的processElement之后、snapshotState之前的阶段。这里能拿到本批次写入涉及的分区和文件组信息,适合捕获分区变化。但这时候 commit 还没发生,不能直接发消息,只能先把变化暂存到状态里。
第二个是StreamWriteOperatorCoordinator的notifyCheckpointComplete方法。Flink 的 checkpoint 完成意味着这一批数据已经持久化,Hudi 的 commit 也在这个阶段推进。在这里可以拿到本次 checkpoint 对应的 commit 元数据,包括 commit 时间、涉及分区、schema 版本等。这是最可靠的发送时机。
实操上,我建议继承StreamWriteOperatorCoordinator,重写notifyCheckpointComplete,在调用父类方法完成 commit 之后,收集本次 commit 的元数据变化并发送。这样既不影响原有 commit 逻辑,又能保证消息在 commit 成功后发出。
public class MetadataSyncCoordinator extends StreamWriteOperatorCoordinator { @Override public void notifyCheckpointComplete(long checkpointId) throws Exception { super.notifyCheckpointComplete(checkpointId); // commit 已完成,收集元数据变化 MetadataChangeEvent event = collectMetadataChange(checkpointId); if (event.hasChange()) { metadataProducer.send(event); } } }3.2 元数据变化事件的字段设计
事件字段设计得好不好,直接决定下游用起来顺不顺手。我踩过的坑是早期字段太少,下游拿到消息还得反查 Hudi 表才能知道具体变了什么,等于同步了个寂寞。后来把字段补全,下游基本可以只靠消息就完成元数据更新。
一个完整的事件至少包含这些字段:表标识(库名+表名)、commit 唯一标识(用 instant time 就行)、变化类型(schema/partition/commit)、变化详情、时间戳、以及 schema 版本号。变化详情根据类型不同而不同:schema 变化要带新增字段名、类型、默认值;分区变化要带分区路径和操作类型(新增/删除);commit 变化要带 commit 类型和涉及的文件数。
| 字段名 | 类型 | 说明 | 是否必填 |
|---|---|---|---|
| tableId | string | 库名.表名 | 是 |
| instantTime | string | Hudi commit 时间戳 | 是 |
| changeType | enum | SCHEMA/PARTITION/COMMIT | 是 |
| changeDetail | json | 变化详情,结构随类型变化 | 是 |
| schemaVersion | int | schema 版本号,每次 schema 变化递增 | 否 |
| timestamp | long | 事件产生时间 | 是 |
提示:instantTime 一定要带上,下游用它做幂等去重。同一个 commit 的消息可能因为重试发多次,下游按 instantTime 去重就不会重复更新。
3.3 schema 变化的捕获与序列化
schema 变化是最容易出问题的部分。Hudi 的 schema 存在表的.hoodie目录下,Flink writer 在启动时会加载一次,之后如果上游数据带来新字段,会触发 schema evolution。捕获 schema 变化的关键是拿到 evolution 前后的 schema 做 diff。
实操上,可以在StreamWriteFunction初始化时把当前 schema 缓存下来,每次processElement时用SchemaUtils做一次比对。如果发现新 schema 和缓存的不一致,就记录下差异字段,更新缓存,并把变化暂存。等 commit 成功后,把暂存的 schema 变化一起发出去。
序列化用 JSON 就够了,别上 Avro 或者 Protobuf,除非你的下游强制要求。JSON 可读性好,排查问题方便,元数据事件本身也不大,性能不是瓶颈。schema 字段的序列化要注意把字段名、类型、是否 nullable、默认值都带上,下游重建 schema 时需要这些信息。
3.4 分区变化的批量聚合
分区变化的特点是量大且碎。一次 checkpoint 可能涉及几十个新分区,如果每个分区发一条消息,消息量会爆炸。我的做法是在StreamWriteFunction里用一个Set收集本批次涉及的所有分区,等 commit 成功后,把整个 Set 作为一条消息的 changeDetail 发出去。
聚合的粒度是“一次 commit 一条消息”,而不是“一个分区一条消息”。这样消息量从几十条降到一条,下游处理压力小很多。changeDetail 里用一个数组列出所有变化的分区路径和操作类型即可。
{ "tableId": "lake.orders", "instantTime": "20240115103000", "changeType": "PARTITION", "changeDetail": { "partitions": [ {"path": "dt=2024-01-15", "op": "ADD"}, {"path": "dt=2024-01-16", "op": "ADD"} ] }, "timestamp": 1705300200000 }3.5 消息投递的可靠性保障
消息发出去不等于下游收到了,中间可能丢、可能重、可能乱序。要保证可靠性,至少做三件事。
第一是发送确认。用消息队列的同步发送或者带回调的异步发送,确认 broker 收到了才算成功。如果发送失败,要重试,重试次数用尽后记录到死信或者本地文件,不能静默丢弃。
第二是幂等。下游按tableId + instantTime + changeType做去重,重复消息直接忽略。这样即使发送端重试导致重复,下游也不会重复更新。
第三是顺序。同一个表的元数据变化必须有序,否则下游可能先收到新 schema 再收到旧 schema,把 schema 回退了。解决办法是用tableId作为消息队列的分区键,同一个表的消息进同一个分区,天然有序。
注意:不要用随机分区键。我见过有人为了负载均衡用随机键,结果同一个表的 schema 变化乱序到达,下游 schema 版本号来回跳,排查了半天才发现是分区键的问题。
4. 实操过程与核心环节实现
4.1 环境准备与依赖确认
动手之前先把环境理清楚。Flink 版本建议 1.14 及以上,Hudi 版本 0.12 及以上,因为StreamWriteOperatorCoordinator的扩展点在 0.12 之后才比较稳定。消息队列用你团队已经在用的就行,不挑。
依赖上,除了 Hudi 和 Flink 本身的依赖,还需要消息队列的客户端。如果消息队列客户端和 Flink 的依赖有冲突,用 shade 方式打包,把客户端依赖 relocate 到独立包名,避免类冲突。这个坑很常见,尤其是消息队列客户端自带一些常见库的旧版本时。
<dependency> <groupId>org.apache.hudi</groupId> <artifactId>hudi-flink1.14-bundle</artifactId> <version>0.12.0</version> </dependency>4.2 自定义 Coordinator 的完整实现
核心实现分三步:继承 Coordinator、重写 commit 回调、注入消息发送器。
第一步,继承StreamWriteOperatorCoordinator,在构造方法里初始化消息发送器。发送器建议做成可配置的,通过 Flink 配置传入消息队列地址和 topic,方便不同环境切换。
第二步,重写notifyCheckpointComplete。先调super完成 Hudi 自身的 commit,然后从eventBuffer里取出本批次暂存的元数据变化,组装成事件,调用发送器发送。发送失败要重试,重试逻辑封装在发送器里。
第三步,在StreamWriteFunction里做变化捕获。schema 变化在processElement里比对,分区变化在文件组分配时记录,都暂存到eventBuffer。eventBuffer用 Flink 的ListState存,保证 checkpoint 时状态不丢。
public class MetadataSyncCoordinator extends StreamWriteOperatorCoordinator { private transient MetadataProducer producer; private transient ListState<MetadataChange> eventBuffer; @Override public void notifyCheckpointComplete(long checkpointId) throws Exception { super.notifyCheckpointComplete(checkpointId); List<MetadataChange> changes = new ArrayList<>(); for (MetadataChange c : eventBuffer.get()) { changes.add(c); } if (!changes.isEmpty()) { MetadataChangeEvent event = MetadataChangeEvent.from(changes); producer.sendWithRetry(event, 3); eventBuffer.clear(); } } }4.3 消息发送器的实现与参数选择
发送器不用搞太复杂,一个类搞定:初始化连接、发送、重试、关闭。参数上重点说三个。
批量大小:一次发送的消息条数。元数据事件本身不大,批量大小设 100 到 500 都行。设太小网络往返多,设太大延迟高。我一般设 200。
重试次数:3 次足够。重试间隔用指数退避,第一次 100ms,第二次 500ms,第三次 2s。这样既能应对瞬时抖动,又不会在 broker 真挂了的时候无限重试。
发送超时:设 5s。超过 5s 没确认就认为失败,触发重试。超时设太长会拖慢 checkpoint,设太短会误判。
public class MetadataProducer { private final Producer<String, String> producer; private final int maxRetry = 3; private final long[] backoff = {100, 500, 2000}; public void sendWithRetry(MetadataChangeEvent event, int retry) { for (int i = 0; i <= maxRetry; i++) { try { producer.send(new ProducerRecord<>(topic, event.getTableId(), event.toJson())); return; } catch (Exception e) { if (i == maxRetry) { log.error("send failed after retry", e); throw e; } Thread.sleep(backoff[i]); } } } }4.4 下游消费端的对接方式
下游怎么消费,取决于下游是什么系统。如果是 Flink 任务,直接用一个 source 消费消息队列,收到事件后更新本地元数据缓存。如果是查询引擎,可以写一个轻量服务消费消息,把元数据写到查询引擎能读的存储里。
消费端的关键是幂等和顺序。幂等按tableId + instantTime + changeType去重,顺序靠消息队列的分区键保证。消费端还要处理一种情况:启动时元数据是空的,需要先全量拉一次 Hudi 表的元数据做初始化,然后再增量消费消息。这个初始化步骤不能省,否则消费端在收到第一条增量消息之前是“瞎”的。
提示:消费端初始化时,全量拉取和增量消费之间可能有一个时间窗口,窗口内的变化会丢。解决办法是先订阅消息队列并缓存消息,再全量拉取,拉取完成后回放缓存的消息。这样窗口内的变化不会丢。
4.5 一次完整的同步流程演示
把上面的环节串起来,一次完整的同步流程是这样的。
Flink 作业启动,Coordinator 初始化,消息发送器建立连接。StreamWriteFunction加载 Hudi 表当前 schema 并缓存。上游数据进来,processElement处理每条记录,发现新字段,触发 schema evolution,记录 schema 变化到eventBuffer。同时文件组分配时发现新分区,记录分区变化到eventBuffer。
Checkpoint 触发,状态持久化。Checkpoint 完成,notifyCheckpointComplete被调用,super完成 Hudi commit。然后从eventBuffer取出本批次所有变化,组装成事件,发送器发送到消息队列。发送成功后清空eventBuffer。
下游消费端收到事件,按tableId找到本地元数据缓存,按instantTime判断是否已处理,然后应用变化:schema 变化更新字段列表,分区变化更新分区列表,commit 变化更新最新 commit 时间。更新完成后,下游的查询和校验就能用上最新元数据了。
整个流程从数据写入到下游元数据更新,延迟通常在秒级,比定时轮询的分钟级提升明显。
5. 常见问题与排查技巧实录
5.1 消息重复导致下游元数据回退
现象:下游 schema 版本号来回跳,有时候新字段刚加上又没了。
原因:消息重复发送,且下游没有做幂等,旧消息覆盖了新消息。
排查:检查发送端是否重试导致重复,检查下游是否有去重逻辑。用instantTime排序,只应用比当前版本新的消息。
解决:下游按tableId + instantTime + changeType去重,并且只应用instantTime大于本地记录的消息。发送端重试时保持instantTime不变,这样重复消息会被下游识别并忽略。
5.2 checkpoint 失败导致元数据丢失
现象:某些 commit 的元数据变化没有同步到下游,下游元数据落后。
原因:eventBuffer存在ListState里,如果 checkpoint 失败,状态回滚,eventBuffer里的变化就丢了。
排查:对比 Hudi 表的 commit 列表和下游收到的消息列表,找出缺失的 commit。
解决:eventBuffer的清空必须放在消息发送成功之后。如果发送失败,eventBuffer不清空,下次 checkpoint 会重新发送。另外,下游要支持全量对账,定期用 Hudi 表的元数据和本地元数据做一次比对,发现落后就补上。
5.3 schema evolution 与消息发送的时序问题
现象:下游收到 schema 变化消息,但用新 schema 去查数据时查不到新字段的数据。
原因:schema 变化消息发得比数据 commit 早,下游以为新字段的数据已经可查,实际上还没有。
排查:检查消息发送时机是否在 commit 之后。
解决:所有元数据变化消息必须在 commit 成功之后发送。schema evolution 本身在写入前发生,但消息要等到 commit 成功才发。这样下游收到消息时,数据已经可查。
5.4 消息队列积压导致同步延迟
现象:下游元数据更新延迟越来越大,消息队列消费 lag 持续增长。
原因:下游消费速度跟不上生产速度,或者消息队列分区数不够。
排查:看消息队列的消费 lag 和分区数。如果 lag 持续增长,说明消费能力不足。
解决:增加消息队列分区数,同时增加下游消费并行度。分区键用tableId,增加分区后同一个表的消息仍然有序。如果单个表的消息量特别大,可以考虑把tableId和changeType组合作为分区键,进一步分散压力。
5.5 常见问题速查表
| 问题 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 元数据回退 | 消息重复且下游无幂等 | 检查下游去重逻辑 | 按 instantTime 去重,只应用新消息 |
| 元数据丢失 | checkpoint 失败,buffer 清空过早 | 对比 commit 列表和消息列表 | buffer 发送成功后再清空,下游定期对账 |
| 新字段查不到数据 | 消息发送早于 commit | 检查发送时机 | 消息在 commit 成功后发送 |
| 同步延迟增长 | 消息队列积压 | 看消费 lag 和分区数 | 增加分区和消费并行度 |
| 发送失败无感知 | 发送端静默丢弃 | 检查发送日志和死信 | 加重试和死信记录 |
5.6 几个我踩过的坑
第一个坑是在 processElement 里直接发消息。早期图省事,在processElement里发现变化就发,结果 commit 失败时消息已经发出去了,下游元数据和实际数据不一致。后来改成暂存到 buffer,commit 成功后再发,问题解决。
第二个坑是用随机分区键。为了负载均衡用随机键,结果同一个表的 schema 变化乱序,下游 schema 版本号来回跳。改成tableId做分区键后,顺序问题消失。
第三个坑是忘记处理消费端初始化。消费端启动时元数据是空的,第一条增量消息来的时候没有基础元数据可以应用,直接报错。后来加了启动时全量拉取,并且用“先订阅缓存、再全量拉取、最后回放缓存”的方式避免窗口丢失。
第四个坑是消息体太大。有一次一个 commit 涉及上千个分区,changeDetail 里的分区数组巨大,消息体超过消息队列的大小限制,发送失败。后来改成超过一定数量就分片发送,每个分片带一个分片序号和总分片数,下游收齐后合并。
6. 性能调优与扩展思路
6.1 发送频率与 checkpoint 间隔的权衡
元数据同步的延迟直接受 checkpoint 间隔影响。checkpoint 间隔 1 分钟,元数据同步延迟就是分钟级;间隔 10 秒,延迟就是秒级。但 checkpoint 间隔太短会增加 Flink 作业的负担,状态持久化和 commit 都会更频繁。
我的经验是 checkpoint 间隔设 30 秒到 1 分钟比较平衡。如果下游对元数据延迟特别敏感,可以设 10 秒,但要观察作业的 checkpoint 成功率和延迟,避免因为 checkpoint 太频繁导致反压。
另外,不是每个 checkpoint 都有元数据变化。如果本批次没有变化,eventBuffer是空的,不发送消息。这样在数据平稳期,消息量很小,不会给消息队列和下游造成压力。
6.2 大表元数据的增量同步优化
对于分区特别多的大表,元数据本身可能就很大。全量同步一次要传几 MB 甚至几十 MB,增量同步虽然小,但分区变化多的时候也不小。
优化思路是只同步变化的部分,并且做压缩。分区变化只传变化的分区路径,不传全量分区列表。schema 变化只传 diff,不传完整 schema。消息体用 gzip 压缩,通常能压到原来的三分之一。
如果单个表的分区变化实在太频繁,可以考虑按分区前缀聚合。比如dt=2024-01-15和dt=2024-01-16都是dt=前缀,可以聚合成一条“dt 分区新增两个值”的消息,减少消息条数。
6.3 多表场景下的隔离与限流
一个 Flink 作业可能同时写多张 Hudi 表,每张表的元数据变化都要同步。多表场景下要注意隔离和限流。
隔离是指一张表的元数据同步出问题不能影响其他表。发送器按表维度做隔离,一张表发送失败重试时,其他表的消息正常发送。消息队列按tableId分区,一张表的消息积压不影响其他表。
限流是指防止某张表的元数据变化过于频繁把消息队列打满。可以按表设置发送速率上限,超过上限的消息暂存到本地,等速率降下来再发。或者用消息队列的配额功能,给每张表分配独立的配额。
6.4 与元数据服务的联动
如果团队有统一的元数据服务(比如数据目录、数据地图),元数据同步的终点应该是这个服务,而不是各个下游各自维护。同步方案把变化发给元数据服务,元数据服务更新后再通知各个下游。
这样做的好处是元数据只有一份,不会出现多个下游元数据不一致的情况。代价是元数据服务要支持实时更新和高并发查询,对它的要求更高。如果元数据服务暂时不支持实时更新,可以先让下游各自消费,等元数据服务能力跟上再切换。
6.5 监控与告警指标
同步链路要监控的指标不多,但每个都关键。
发送成功率:发送成功数除以发送总数,低于 99% 就告警。这个指标直接反映同步链路是否健康。
发送延迟:从 commit 完成到消息发送成功的时间,P99 超过 5 秒就告警。延迟高说明消息队列或网络有问题。
消费 lag:消息队列的消费积压量,持续增长就告警。lag 增长说明下游消费能力不足。
元数据版本差:Hudi 表的最新 commit 时间和下游元数据的最新 commit 时间之差,超过阈值就告警。这个指标最直接反映同步是否落后。
# 监控指标示例 metadata_sync_send_success_rate{table="orders"} 0.999 metadata_sync_send_latency_p99{table="orders"} 1.2 metadata_sync_consume_lag{table="orders"} 0 metadata_sync_version_gap_seconds{table="orders"} 27. 我在实际项目中的几点体会
这套方案我在几个实时湖仓项目里都用过,整体效果符合预期,元数据同步延迟从原来的分钟级降到秒级,下游因为元数据过期导致的问题基本消失。但有几个体会值得分享。
第一,不要追求零延迟。元数据同步再快也有延迟,下游要能容忍秒级的元数据滞后。如果下游要求强一致,那应该直接查 Hudi 表本身,而不是依赖同步的元数据缓存。同步方案解决的是“最终一致且延迟尽量低”,不是“强一致”。
第二,下游的幂等和容错比发送端更重要。发送端做得再好也可能重复或丢失,下游如果有完善的幂等和对账机制,就能兜住大部分问题。我在设计时会把更多精力放在下游的健壮性上。
第三,从小表开始试点。不要一上来就在核心大表上跑,先找一张变化不频繁的小表验证链路,跑通后再逐步扩大范围。这样出问题时影响面小,排查也容易。
第四,保留全量对账通道。增量同步再可靠也可能有遗漏,定期用全量对账兜底是必要的。对账频率不用太高,一天一次就够,发现差异就触发全量修复。
最后分享一个小技巧:在消息体里加一个source字段,标记这条消息是增量同步发的还是全量对账发的。下游收到全量对账消息时,直接用全量元数据覆盖本地,不做增量合并。这样对账逻辑和增量逻辑分开,实现简单,也不容易出错。