资讯中心

Flink生产实战:从窗口乱序处理到CDC管道构建与作业运维

📅 2026/7/29 10:55:34
Flink生产实战:从窗口乱序处理到CDC管道构建与作业运维
1. 从“四大基石”到“生产实战”为什么你的Flink学习不能止步于Demo如果你已经跟着上一篇笔记把Flink的编程模型、DataStream API和状态管理这些基础概念都过了一遍甚至自己动手写了几个WordCount或者实时统计的Demo感觉已经“入门”了。那么恭喜你也“恭喜”你因为你即将踏入一个更复杂、也更真实的世界。很多朋友学到这里会有一个错觉Flink的核心API我都用过了剩下的不就是业务逻辑的堆砌吗这个想法恰恰是很多项目从“玩具”走向“生产”时栽的第一个跟头。我见过太多团队Demo跑得飞快一到生产环境就问题频发作业莫名其妙挂掉数据延迟飙升资源消耗像个无底洞甚至数据对不上账。问题的根源往往不在于业务逻辑有多复杂而在于对Flink这个分布式系统的“生产级特性”理解不够。所谓“四大基石”——时间、状态、窗口、检查点在Demo里你可能只是调用了几个API但在生产环境它们每一个背后都牵扯着一系列的配置、调优和异常处理逻辑。比如你用了窗口那窗口的触发策略、延迟数据处理、状态清理State TTL都配置对了吗你启用了检查点那状态后端选对了吗Savepoint恢复数据时遇到算子链变化或状态不兼容怎么办这篇笔记我们就抛开那些简单的示例直接切入到那些让Flink作业真正稳定、高效运行的核心生产实践。我们会围绕几个从网络热词和实际工单中提炼出的高频痛点展开窗口的深度配置与乱序处理、与外部系统如JDBC、Hive交互的稳定性、利用CDC构建实时数据管道的核心细节以及作业生命周期管理Savepoint/恢复的避坑指南。目标不是让你再写一个WordCount而是让你有能力去诊断和解决“flink jdbc连接器异常”、“flink not found hive conf”、“flink savepoint 恢复 数据”这些真实问题。2. 窗口详解不仅仅是window()和apply()在Demo里我们可能这样写一个滚动窗口dataStream.keyBy(...).timeWindow(Time.minutes(5)).sum(...)。这行代码在生产环境中几乎是不完整的。窗口是流处理的核心抽象但也是一个“陷阱”高发区。2.1 窗口的核心机制与触发器策略首先我们必须理解Flink窗口的两个核心概念窗口分配器和触发器。我们常用的timeWindow是分配器它决定了数据该进入哪个时间桶。而触发器决定了什么时候对这个桶里的数据进行计算。默认情况下基于处理时间的窗口会在系统时间到达窗口结束时间时触发基于事件时间的窗口会在水位线越过窗口结束时间时触发。但生产场景复杂得多。比如一个监控系统我们希望在每分钟窗口内一旦有错误数量超过阈值就立即告警而不是等到整分钟结束。这就需要自定义触发器。DataStreamEvent stream ...; stream .keyBy(Event::getServiceId) .window(TumblingEventTimeWindows.of(Time.minutes(1))) // 分配器1分钟滚动窗口 .trigger(new MyCustomTrigger()) // 自定义触发器 .process(new MyWindowProcessFunction()); // 一个简单的自定义触发器示例每来一条数据且该数据是错误事件就触发计算 public static class MyCustomTrigger extends TriggerEvent, TimeWindow { Override public TriggerResult onElement(Event element, long timestamp, TimeWindow window, TriggerContext ctx) throws Exception { if (element.isError()) { // 立即触发窗口计算并清空窗口状态但保留窗口因为窗口还没结束 return TriggerResult.FIRE; } // 注册一个事件时间定时器在窗口结束时触发 ctx.registerEventTimeTimer(window.maxTimestamp()); return TriggerResult.CONTINUE; } Override public TriggerResult onEventTime(long time, TimeWindow window, TriggerContext ctx) throws Exception { // 事件时间到达窗口结束时触发计算并清除窗口 return TriggerResult.FIRE_AND_PURGE; } // onProcessingTime 和 clear 方法也需要实现... }这个例子揭示了触发器可以让你更精细地控制窗口计算的时机。另一个高级特性是移除器它可以在触发器触发后、计算执行前有选择地移除窗口中的某些元素但实际应用相对较少。2.2 处理乱序数据水位线、延迟与侧输出流事件时间窗口是处理乱序数据的利器但其正确性严重依赖于水位线的生成策略。BoundedOutOfOrdernessTimestampExtractor已废弃或其替代者WatermarkStrategy.forBoundedOutOfOrderness是常用选择。WatermarkStrategyEvent strategy WatermarkStrategy .EventforBoundedOutOfOrderness(Duration.ofSeconds(5)) // 允许5秒乱序 .withTimestampAssigner((event, timestamp) - event.getTimestamp()); DataStreamEvent withTimestampsAndWatermarks stream.assignTimestampsAndWatermarks(strategy);这里的关键是Duration.ofSeconds(5)这个参数。设置太小可能导致水位线推进过快晚到的合法数据被丢弃设置太大会导致窗口结果输出延迟变高状态保持时间变长内存压力增大。这个值需要根据业务数据源的真实乱序程度来权衡通常通过观察数据流中事件时间戳与处理时间戳的差值分布来确定。即使设置了允许延迟也总会有数据晚于水位线窗口已关闭才到达。默认情况下这些数据会被丢弃。这对于计费、对账等不允许丢数据的场景是不可接受的。此时必须使用侧输出流来收集这些迟到数据。OutputTagEvent lateDataTag new OutputTagEvent(late-data) {}; SingleOutputStreamOperatorResult mainStream withTimestampsAndWatermarks .keyBy(...) .window(TumblingEventTimeWindows.of(Time.minutes(1))) .allowedLateness(Time.seconds(30)) // 允许30秒的额外延迟在此期间窗口状态仍保留迟到数据会触发窗口再次计算 .sideOutputLateData(lateDataTag) // 超过允许延迟期的数据输出到侧输出流 .process(new MyProcessFunction()); DataStreamEvent lateDataStream mainStream.getSideOutput(lateDataTag); // 对lateDataStream进行处理例如合并到下一个窗口或记录到日志/特定存储这里有一个非常重要的生产实践对于allowedLateness设置的时间窗口Flink会一直保留其状态直到窗口最大时间戳 allowedLateness 1ms。如果设置allowedLateness(Time.days(1))就意味着每个窗口的状态要多保存一天这对状态后端是巨大的压力。因此务必谨慎设置允许延迟时间并配合合理的状态TTL。2.3 状态清理与性能优化窗口状态如果不清理会无限增长。对于事件时间窗口Flink会在窗口最大时间戳 allowedLateness 1ms后自动清理。对于处理时间窗口或者使用了全局窗口的情况则需要手动配置状态生存时间。// 在窗口算子后配置状态的TTL StateTtlConfig ttlConfig StateTtlConfig .newBuilder(Time.hours(24)) // 状态保留24小时 .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite) // 仅在创建和写入时更新TTL .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired) // 永不返回过期数据 .cleanupInBackground() // 启用后台清理RocksDB状态后端 .build(); windowedStream .process(new MyProcessFunction()) .name(my-window-processor) .uid(my-window-processor-uid) // 必须设置UID用于Savepoint恢复 .map(new MyMapFunction()) .withTimestampsAndWatermarks(...); // 如果下游还需要可以再次指定注意cleanupInBackground()和cleanupFullSnapshot()是针对RocksDB状态后端的优化。对于FsStateBackend堆内存过期状态会在访问时惰性删除或在检查点时从快照中排除。在生产中RocksDB是处理大状态作业的首选因为它能溢出到磁盘。另一个性能优化点是窗口预聚合。如果窗口计算是sum、min、max这类可合并的聚合使用reduce或aggregate函数会比ProcessWindowFunction高效得多因为前者会增量聚合每个元素到来时只更新一个小的累加器状态而后者会在触发时拿到窗口所有元素进行全量计算状态压力大。// 高效做法增量聚合 stream .keyBy(...) .window(...) .aggregate(new MyAggregateFunction(), new MyWindowFunction()); // AggregateFunction增量聚合WindowFunction输出结果 // 低效做法仅当需要全量数据时才用 stream .keyBy(...) .window(...) .process(new MyProcessWindowFunction()); // 触发时拿到Iterable所有元素3. 连接外部系统稳定性压倒一切“flink jdbc连接器异常”、“flink not found hive conf”这类错误是集成环节的典型问题。与外部系统交互必须考虑连接管理、容错、性能与一致性。3.1 JDBC连接连接池、幂等写入与异常重试Flink官方提供的JdbcSink和JdbcInputFormat比较基础生产环境直接使用容易遇到连接泄漏、写入性能瓶颈和容错问题。更推荐使用异步I/O或自定义RichSinkFunction配合连接池。为什么用异步I/O同步数据库操作会阻塞算子线程严重影响吞吐量。异步I/O允许同时处理多个请求通过回调处理结果极大提升了并发能力。// 1. 定义异步查询函数继承RichAsyncFunction public class AsyncJdbcQuery extends RichAsyncFunctionString, EnrichedData { private transient DataSource dataSource; Override public void open(Configuration parameters) throws Exception { // 初始化HikariCP等连接池 HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/test); config.setUsername(user); config.setPassword(pass); config.setMaximumPoolSize(20); // 连接池大小 config.setConnectionTimeout(30000); dataSource new HikariDataSource(config); } Override public void asyncInvoke(String key, ResultFutureEnrichedData resultFuture) throws Exception { // 异步查询 CompletableFuture.supplyAsync(() - { try (Connection conn dataSource.getConnection(); PreparedStatement stmt conn.prepareStatement(SELECT info FROM dim_table WHERE id?)) { stmt.setString(1, key); ResultSet rs stmt.executeQuery(); if (rs.next()) { return new EnrichedData(key, rs.getString(info)); } } catch (SQLException e) { throw new CompletionException(e); } return null; }, executor).whenComplete((result, throwable) - { // 回调完成或异常时将结果传递给ResultFuture if (throwable ! null) { resultFuture.completeExceptionally(throwable); } else { resultFuture.complete(Collections.singleton(result)); } }); } // 需要重写timeout方法处理超时 } // 2. 在流上应用异步查询 DataStreamString inputStream ...; AsyncDataStream.unorderedWait( inputStream, new AsyncJdbcQuery(), 5000, // 超时时间 5秒 TimeUnit.MILLISECONDS, 100 // 最大并发请求数 ).print();关键配置解析unorderedWaitvsorderedWaitunorderedWait性能更好结果一旦完成就立刻下发不保证顺序orderedWait保证输出顺序与输入顺序一致但会引入等待延迟。除非业务强依赖顺序否则用unorderedWait。超时时间必须设置。防止慢查询或网络问题导致请求永远挂起阻塞整个管道。最大并发请求数限制同时进行的异步请求数是对数据库的一种保护避免瞬时压力过大。对于写入Sink除了异步还要重点考虑幂等性和批量提交。利用RichSinkFunction在invoke方法中实现批量缓存在checkpointComplete回调中批量提交事务可以确保精确一次语义。public class JdbcBatchSink extends RichSinkFunctionMyData implements CheckpointedFunction { private ListMyData batch; private transient Connection connection; private transient PreparedStatement statement; private final int batchSize 1000; Override public void open(Configuration parameters) throws Exception { batch new ArrayList(); connection DriverManager.getConnection(...); connection.setAutoCommit(false); // 关闭自动提交 statement connection.prepareStatement(INSERT INTO table (id, value) VALUES (?, ?) ON DUPLICATE KEY UPDATE value?); } Override public void invoke(MyData value, Context context) throws Exception { // 构造幂等写入SQL如使用ON DUPLICATE KEY UPDATE, MERGE INTO statement.setString(1, value.getId()); statement.setDouble(2, value.getValue()); statement.setDouble(3, value.getValue()); // 用于更新 statement.addBatch(); batch.add(value); if (batch.size() batchSize) { statement.executeBatch(); connection.commit(); batch.clear(); } } Override public void snapshotState(FunctionSnapshotContext context) throws Exception { // 在检查点快照前确保当前批次的数据已提交 if (!batch.isEmpty()) { statement.executeBatch(); connection.commit(); batch.clear(); } } Override public void close() throws Exception { if (statement ! null) statement.close(); if (connection ! null) connection.close(); } }3.2 集成HiveCatalog配置与实时数仓实践“flink not found hive conf”这个错误根本原因是Flink作业没有找到Hive的配置文件如hive-site.xml。Flink通过HiveCatalog来管理Hive元数据实现Flink SQL与Hive表的无缝对接。正确配置HiveCatalog的步骤添加依赖确保Flink作业的classpath中包含Flink连接Hive的JAR包flink-connector-hive_${scala.version}以及对应版本的Hive依赖。提供Hive配置将Hive的hive-site.xml文件放置在作业的类路径下例如在src/main/resources/目录中。这个文件包含了Hive Metastore的地址、Warehouse目录等信息。在代码中创建并使用Catalog// 在TableEnvironment中注册HiveCatalog String name myhive; String defaultDatabase default; String hiveConfDir /path/to/hive-conf; // 或者将hive-site.xml放于resources这里可以传null String version 3.1.2; // 你的Hive版本 HiveCatalog hive new HiveCatalog(name, defaultDatabase, hiveConfDir, version); tableEnv.registerCatalog(myhive, hive); tableEnv.useCatalog(myhive); // 使用该Catalog tableEnv.useDatabase(default); // 现在可以直接查询Hive表 TableResult result tableEnv.executeSql(SELECT * FROM my_hive_table);生产环境常见问题版本兼容性Flink连接器版本、Hive版本和Hadoop版本必须兼容。官方文档有明确的兼容性矩阵务必核对。Metastore高可用生产环境Hive Metastore通常是高可用的。在hive-site.xml中正确配置hive.metastore.uris为高可用地址如thrift://host1:9083,thrift://host2:9083。Kerberos认证如果Hadoop集群启用了Kerberos安全认证需要在Flink作业启动时提供keytab和principal这是一个更复杂的专题涉及JAAS配置。集成Hive后一个典型的实时数仓场景是用Flink CDC实时捕获业务数据库变更通过Flink SQL进行ETL和维度关联最后将结果实时写入Hive表Hive Streaming Sink实现实时数据湖仓一体。-- 假设已注册了MySQL CDC表 orders_cdc 和 Hive Catalog -- 将实时订单数据写入Hive分区表 INSERT INTO myhive.default.dwd_orders PARTITION (dt, hr) -- 按天和小时分区 SELECT order_id, user_id, amount, status, DATE_FORMAT(order_time, yyyy-MM-dd) as dt, DATE_FORMAT(order_time, HH) as hr, PROCTIME() as proc_time FROM orders_cdc WHERE status PAID;这里PROCTIME()是处理时间用于生成分区字段。Hive Streaming Sink会以事务方式向Hive表写入数据小文件问题需要通过调优检查点间隔、并行度或使用Compact Strategy来处理。4. Flink CDC 2.0实战构建稳定可靠的实时数据管道CDC是Change Data Capture的缩写Flink CDC可以直接将数据库的增量变更作为流接入Flink是构建实时数仓的基石。但“在dinky中使用flink cdc pipelinegc不回收”这样的问题说明了使用不当会带来严重副作用。4.1 核心原理与部署模式选择Flink CDC 2.0的核心优势在于全增量一体化读取和无锁读取。它先做一次全量快照然后自动切换到读取数据库的Binlog实现无缝衔接。其底层是通过Debezium作为捕获引擎。部署上主要有两种模式Flink CDC Connector DataStream API / Table API将CDC Connector作为Source使用。这是最常用、最灵活的方式。Flink CDC Pipeline (通过flink-cdc-pipeline模块)一种更高级的封装提供整库同步、表结构自动变更等开箱即用的功能。Dinky中使用的可能就是这种模式。“gc不回收”问题深度排查这个问题通常指向内存泄漏或过大的堆外内存压力。可能的原因及排查方向状态过大CDC Source会为每个表的分片Split维护读取状态。如果同步的表非常多或者历史数据量巨大状态会膨胀。检查作业的状态大小指标。考虑调大RocksDB状态后端的Block Cache和Write Buffer。无界流中的资源累积CDC Source是无限流如果下游处理太慢或发生背压Source端会缓冲数据。检查反压指标和Source端的缓冲队列。需要优化下游算子性能或增加并行度。Pipeline模式的内存管理flink-cdc-pipeline可能在内存中维护了过多的元数据或缓存。查阅对应版本的官方文档和Issue列表看是否有已知的内存问题。尝试调整Pipeline作业的TaskManager堆内存和直接内存比例。网络缓冲与 RocksDB 内存Flink的网络缓冲和RocksDB的Memtable都会使用堆外内存。如果分配不足会导致频繁的GC甚至OOM。确保taskmanager.memory.process.size足够并合理配置taskmanager.memory.task.off-heap.size和taskmanager.network.memory.fraction。Debezium内部缓冲Debezium连接器本身有snapshot.fetch.size、max.queue.size等参数控制快照和增量读取时的缓冲。过大的缓冲会占用更多内存。可以尝试调小这些参数但可能影响吞吐量。一个基本的CDC Source使用示例MySQL// 使用Flink SQL方式更简洁 String sourceDDL CREATE TABLE mysql_source ( id INT, name STRING, description STRING, PRIMARY KEY (id) NOT ENFORCED ) WITH ( connector mysql-cdc, hostname localhost, port 3306, username flinkuser, password flinkpw, database-name inventory, table-name products, server-id 5400-5404, // 为每个并行Source实例指定唯一的server id范围 debezium.snapshot.mode initial // 先做全量快照再读增量 ); tableEnv.executeSql(sourceDDL); TableResult result tableEnv.executeSql(SELECT * FROM mysql_source); result.print();4.2 关键配置与生产调优要让CDC作业稳定运行以下配置至关重要server-id/server-id-range对于MySQL每个读取Binlog的客户端都需要一个唯一的server id。在并行度1时必须配置一个范围如5400-5404Flink会为每个子任务分配一个。scan.startup.mode启动模式。initial默认先全量后增量、latest-offset仅从最新位点开始读增量、timestamp从指定时间戳开始。debezium.*参数可以传递大量Debezium底层配置。例如debezium.snapshot.mode initialdebezium.snapshot.locking.mode none生产环境慎用无锁快照避免锁表但可能获取不到一致性快照。debezium.max.batch.size 2048每批从Binlog读取的最大记录数。debezium.max.queue.size 4096内部队列大小影响内存占用。心跳与超时配置heartbeat.interval 30s可以在Binlog流静止时发送心跳事件帮助Flink推进水位线避免窗口不触发。并行度与分片对于大表可以通过scan.incremental.snapshot.chunk.size控制全量快照时每块的大小实现并行读取。但并行度受限于数据库连接数和负载。生产环境高可用建议保存位点确保检查点已开启。检查点中会保存CDC读取的Binlog位点。作业失败恢复后可以从位点继续读取保证精确一次语义。监控Binlog延迟通过Flink的currentFetchEventTimeLag指标监控数据从产生到被Flink消费的延迟。延迟突然增大可能意味着下游处理瓶颈或网络问题。处理Schema变更如果源表结构发生变化加字段需要规划好如何处理。Flink CDC支持传递Schema变更事件但下游Sink如Kafka、Hive需要能处理这种变更。这是一个需要上下游协同设计的复杂问题。5. 作业运维从Savepoint恢复到指标监控“flink savepoint 恢复 数据”和“flink 上传 job”这些热词指向了作业生命周期的管理。这是保障线上作业稳定性的最后一道防线。5.1 Savepoint与Checkpoint区别与恢复实战Checkpoint是Flink自动定期触发的状态快照用于故障恢复设计目标是轻量和快速。Savepoint是用户手动触发的、全局一致的状态快照用于有计划地停止和恢复作业、版本升级、扩缩容等。创建Savepoint# 通过Flink CLI ./bin/flink savepoint jobId [targetDirectory] # 或在通过REST API提交作业时指定 ./bin/flink run -d -s :savepointPath ./my-job.jar从Savepoint恢复./bin/flink run -s hdfs:///savepoints/savepoint-xxx -d ./my-job.jar恢复时的核心挑战与解决方案算子UID未设置这是恢复失败最常见的原因。Flink通过算子UID来匹配状态。如果代码变更后算子UID变了或从未设置Flink就无法将Savepoint中的状态分配给新算子。黄金法则为每个有状态的算子如keyBy、window、process显式设置.uid(“string”)。stream .keyBy(...) .process(new MyProcessFunction()) .uid(my-processor) // 必须设置 .name(my-processor);状态拓扑变更增加有状态算子新算子没有对应状态会从空状态开始。删除有状态算子对应的状态会被丢弃。修改有状态算子的逻辑如修改ProcessFunction如果UID不变Flink会尝试将旧状态反序列化后分配给新算子。这极其危险必须确保新旧版本的ProcessFunction在序列化格式上兼容否则会导致恢复失败。最佳实践是任何逻辑变更都视为新算子赋予新的UID但这意味着该算子的状态会丢失需要从源头重算。并行度变更从Savepoint恢复时可以指定新的并行度-p。Flink的状态分配策略EvenlySplitState或UnionState会影响状态如何重新分配。对于KeyedStateFlink会根据Key的哈希值将其重新分配到新的并行子任务上这个过程通常是平滑的。5.2 作业提交与资源规划“flink 上传 job”通常指通过REST API或Web UI提交作业。在生产环境更推荐使用Flink Application Mode或Per-Job Mode而不是Session Mode。Session Mode先启动一个Flink集群Session然后向其提交多个作业。作业共享集群资源。缺点是一个作业行为异常如内存泄漏可能影响同集群其他作业资源隔离性差。Per-Job Mode为每个作业单独启动一个集群作业完成后集群释放。资源隔离性好但集群启动有开销。Application Mode这是Per-Job Mode的演进。将用户程序的main()方法在集群上执行而不是在客户端。这解决了Per-Job模式下客户端需要下载所有依赖的负担是生产环境更推荐的方式尤其适合Kubernetes部署。资源规划公式简化估算TaskManager内存总内存 框架堆内存 任务堆内存 任务堆外内存 网络内存 托管内存。任务堆内存你的业务代码和用户数据结构所在。如果作业状态大且使用RocksDB这部分可以不用太大。托管内存用于RocksDB状态后端如果使用和批处理算子排序等。对于RocksDB通常需要设置较大如1GB以上。网络内存用于缓冲网络传输数据。高吞吐作业需要更多。并行度起始并行度可以设置为Source端分区数如Kafka Topic分区数以实现最佳吞吐。后续根据反压情况和CPU使用率调整。5.3 指标体系构建与监控“flink 的指标体系介绍及验证”是运维的眼睛。Flink提供了极其丰富的指标分为系统指标、作业指标、算子指标和用户自定义指标。必须监控的核心指标吞吐与延迟numRecordsInPerSecond/numRecordsOutPerSecond每秒输入/输出记录数反映吞吐。currentFetchEventTimeLagCDC Source事件时间延迟。latency在Source算子处记录从产生到被Source处理的时间需要启用latencyTrackingInterval。反压通过Web UI的拓扑图颜色或backPressuredTimeMsPerSecond指标判断。持续反压是性能瓶颈的标志。检查点checkpointDuration完成一次检查点的时间。持续增长可能意味着状态过大或存储慢。lastCheckpointSize上次检查点的大小。监控其增长趋势。numberOfFailedCheckpoints失败的检查点数量。失败意味着无法保证精确一次。状态stateSize算子状态大小。numSplitsCDC Source对于增量快照监控未完成的分片数。资源heapUsed/heapCommittedJVM堆内存使用。directMemoryUsed堆外内存使用RocksDB、网络缓冲。cpuLoadCPU负载。如何验证与告警将Flink指标通过Metrics Reporter导出到Prometheus Grafana。在Grafana中搭建监控看板并对关键指标如检查点失败、反压比例0.1、延迟超过阈值设置告警规则。例如一个简单的Prometheus告警规则当检查点失败次数在5分钟内大于0时触发groups: - name: flink_alerts rules: - alert: FlinkCheckpointFailed expr: flink_jobmanager_job_numberOfFailedCheckpoints 0 for: 1m labels: severity: critical annotations: summary: Flink作业 {{ $labels.job_name }} 检查点失败真正的生产级Flink应用是一个由健壮的代码、合理的配置、完善的监控和清晰的运维流程共同构成的有机体。它不再是一个简单的流处理程序而是一个需要精心照料的数据系统。从理解窗口的乱序处理到稳定连接外部数据库再到利用CDC构建实时管道最后通过Savepoint和监控保障其持续运行每一步都需要跳出Demo的思维用系统工程的视角去思考和设计。