做 PyFlink DataStream 的同学,十有八九会遇到一个特别没道理的现象:明明只是 map 了一下,运行起来慢得离谱,日志里还到处是 Pickle 相关字样。更离谱的是,你给 map 补了一个返回类型声明,性能立刻不一样,Java 那边的排查也瞬间透明了。这个现象背后不是玄学,而是 PyFlink 的类型推断和序列化器选择机制在起作用。搞清楚“为什么不写类型就会变 Pickle”,你才能真正理解 Types.ROW 和 Types.TUPLE 应该怎么选,以及那些和 Java 互操作时说都说不清的坑到底从哪儿来。这篇文章适合被 PyFlink 序列化问题折磨过的开发者、正在设计实时计算管道的朋友,以及所有想把 Python UDF 性能再抠一抠的人。我会把类型推断过程、ROW 与 TUPLE 的取舍、性能差异、Java 互操作陷阱一次讲透,全部基于实际踩坑经验。
1. 先搞清楚:为什么“没写类型”就默认 Pickle 了
1.1 PyFlink 的类型推断到底在推断什么
PyFlink DataStream API 的底层是 Flink Java 流引擎,Python 一侧只是通过 worker 机制把 UDF 分发给 Python 进程执行。这里就出现了一个根本矛盾:Python 是动态语言,lambda 和普通函数都没有编译期类型签名;而 Java 侧的 Flink 作业从第一个 Source 开始,就必须给每个算子确定一个 TypeInformation。
TypeInformation 是 Flink 类型系统的核心描述符,它决定了数据怎么序列化、分区、排序、状态存取,也决定了下游算子能不能安全地按强类型访问字段。PyFlink 要做的,就是把 Python UDF 的输入输出映射成一个 Java TypeInformation。映射不出来怎么办?它必须硬着头皮选一个“什么都能装”的序列化器兜底。这个兜底方案就是 PickleSerializer。
所以“没写类型就会变 Pickle”这件事,本质上是类型推断失败的默认回退,不是 PyFlink 故意刁难你。PyFlink 官方提供好几种推断来源:Python 3.5+ 的函数注解 type hints、@udf(result_type=...)装饰器、DataStream 算子的output_type参数,以及部分场景下从上游算子继承类型。这些途径都没命中时,走 Pickle 就是最后一条路。
我见过不少新人在 map 里写入返回一个 dict,然后觉得 PyFlink 应该跟 Python 一样聪明,能自动知道 dict 里有两个字段。但这里有个关键点:PyFlink 推断的是类型信息,不是数据的运行时结构。dict 本身在 Java TypeInformation 里没有一个标准的对应物,它既不是 Row 也不是 Tuple,更不是某种 POJO。所以 PyFlink 只能把你的 dict 当作一个不透明的 Python 对象,整体塞进 Pickle 序列化通道。
换句话说,类型推断要解决的从来不是“这个返回值是 dict 还是 list”,而是“这个返回值在 Java 流里应该以什么 TypeInformation 存在”。不理解这一点,后面所有类型声明代码都会写得很别扭。
1.2 推断失败后 PickleSerializer 是怎么“兜底”的
一旦 PyFlink 决定使用 PickleSerializer,数据流动是这样的:Python worker 执行完你的 UDF,把返回对象交给 Python 侧的 coder,调用pickle.dumps把整个对象变成一个二进制字节串;这个字节串穿过 Py4J 桥回到 Java 端;Java 端把它当普通 bytes 看待,存储、shuffle、交给下一个算子。
如果你在中间做了key_by或partition,Flink 在 Java 端对这个 bytes 做网络传输时,依然只能把它当一整块不可拆分的负载。它看不到“这个 bytes 里有一个 int 字段可以用于哈希”,也看不到“这个 bytes 可以按照某个字段做排序”。所有本来可以下推到 Java 引擎的优化,到这里全部失效。
更隐蔽的是,Pickle 出来的 bytes 里往往包含了对象类的模块路径和属性名。同一个字段名在不同 Python 模块里出现,哪怕结构完全一致,Pickle 出来的字节也可能不同。这会导致一个非常头疼的问题:你以为数据是稳定的,实际一个 import 路径变化,就可能让两端序列化结果对不上。
另外要注意,PyFlink 里 Pickle 并不会因为你在 Java 侧拿到了 bytes 就停止影响。Flink 的 KeyedStream 后面如果要接状态,这个 bytes 还会被 Flink 自己的状态序列化器再处理一次。也就是说,Pickle 的 overhead 不仅发生在 Python 与 Java 的交界处,还会沿着整条流继续传播。你在 Python 端图省事省下的类型声明,最终是让整条管道替你买单。
1.3 Pickle 作为默认序列化协议的问题清单
Pickle 本身不是不好,它是 Python 生态最通用的对象序列化方案,但放在 PyFlink DataStream 这种跨语言、高吞吐、强类型的环境里,问题就很具体了。
第一个问题是性能。Pickle 的对象图遍历很重,而且要嵌入类的模块信息,产物体积通常比紧凑的二进制字段编码大不少。在高吞吐场景下,每一条数据都多出几十字节的 pickle 负载,网络压力、GC 压力、JVM 堆占用都会同步放大。
第二个问题是 Java 互操作几乎为零。Java 算子拿到的是二进制 blob,它没有 TypeInformation 说“这个 bytes 是个 Row,第一个字段是整型”。你想在 Java 侧调用row.getField("name")完全做不到,因为 Java 侧根本没有 row 这个概念。这也是很多 Java 与 Python 混合团队吵架的根源之一。
第三个问题是兼容性和安全。Python 版本一升级,pickle 协议可能变化;代码类结构一调整,老数据反序列化直接失败。更严重的,pickle 加载不可信内容存在任意代码执行风险。数据管道中如果上游数据里混入了恶意构造的 pickle bytes,下游loads就等于把执行权交出去了。这个坑在实时数仓里很容易被忽视,因为没人会拿安全审计的眼光去查每个序列化器。
顺带回答一个最近总被问到的词:“big pickle”不是什么新模型,不少人用它来形容数据集大到 pickle 一下要卡半天这件事;也见过有人问 “a dill pickle”,dill 确实是 pickle 的增强版,能多序列化一些 Python 对象,但它在 PyFlink 里同样解决不了跨语言和性能问题,反而更容易引入兼容性负担。所以别指望换一个 pickle 变体,核心还是要回到类型声明上做文章。
2. Types.ROW 与 Types.TUPLE:看起来像,用起来是两个世界
2.1 从对象形态理解 ROW 和 TUPLE
在 PyFlink 里,Types.ROW和Types.TUPLE都是复合类型,但它们在语义、访问方式、生态适配上的差异非常大。简单说:ROW 是有名有姓的结构化行,TUPLE 是纯按位置组合的元组。
先看对象形态。PyFlink 的 ROW 对应pyflink.common.row.Row对象,你可以像用 dict 一样按字段名取数据,也能按索引取。TUPLE 则直接对应 Python 原生 tuple,或者更准确地说,它在 Java 侧对应 Flink 的 TupleN 类,比如 Tuple2、Tuple3。
from pyflink.common import Types from pyflink.common.row import Row # ROW row = Row("alice", 18) print(row[0]) # alice,按位置取值 # 如果是 ROW_NAMED 声明的类型,还可以按名字取 # row["name"] # TUPLE t = ("alice", 18) print(t[0]) # alice两者最大的区别在于字段名元数据。Types.ROW([Types.STRING(), Types.INT()])创建出来的 ROW 没有真实字段名,Flink 内部通常用 f0、f1 这种占位名顶着;Types.ROW_NAMED(["name", "age"], [Types.STRING(), Types.INT()])才真正把字段名绑定进 TypeInformation。而 TUPLE 从来就没有字段名这个概念,它靠位置说话。
用生活化一点的类比:ROW 是带列名的 Excel 表,TUPLE 是只按顺序排好的格子。做数据仓库的人习惯描述性 schema,写算法的人更习惯紧凑元组。这两者的取舍直接体现在你的下游能干什么事。
2.2 什么时候选 ROW,什么时候选 TUPLE
选 ROW 还是 TUPLE,不能凭感觉,得看数据要流向哪里。
如果你要把数据接到 Table API、SQL、JDBC Sink、Kafka JSON 格式,或者任何需要 schema 语义的场景,直接选Types.ROW_NAMED。因为 Table/SQL 生态是强 schema 体系,字段名是跨作业协作的契约。你用一个 TUPLE 去接 SQL,会立刻发现缺字段名,要么在注册视图时临时补 schema,要么就等着报错。
如果你只是在一个 UDF 内部把两个值临时绑在一起传给下一个 Python 算子,没有跨语言、没有 schema 诉求,选Types.TUPLE更轻。代码短、访问快,类型定义也简洁。它没有 ROW 那么多包装逻辑,也没有字段名匹配的负担。
这里有一个我常跟团队强调的边界:说明你用 ROW 是为了“以后能拿到字段名”,但你又不同时声明ROW_NAMED,最后 Java 侧看到的字段名还是 f0、f1。这等于白花了 ROW 的开销,却没享受到 ROW 的好处。每次用它,先问自己“这个字段名到底有没有人用”。没有,就换 TUPLE;有,就用 ROW_NAMED,别用裸的 ROW。
另外要留意,Types.ROW里的每个字段在 Java 侧是 FlinkRow对象,它允许字段级 null,这对可能出现的空值场景更友好。而 TUPLE 的定位是紧凑组合,对 null 的支持和语义没 ROW 那么顺。如果业务里字段经常为空,我倾向于直接上 ROW_NAMED。
2.3 声明类型的正确姿势
声明类型的方法有好几种,但目的都一样:让 PyFlink 在生成 Java TypeInformation 时不要瞎猜。
方式一,用 DataStream 算子的output_type参数:
from pyflink.common import Types from pyflink.common.row import Row from pyflink.datastream import StreamExecutionEnvironment env = StreamExecutionEnvironment.get_execution_environment() source = env.from_collection([ (1, "alice"), (2, "bob"), ]) def enrich(x): return Row(x[0], x[1]) # 不声明:Pickle bad = source.map(enrich) # 声明 ROW_NAMED:Java 侧是带字段名的 Row good = source.map(enrich, output_type=Types.ROW_NAMED( ["id", "name"], [Types.INT(), Types.STRING()] ))方式二,用@udf装饰器。这个在 Table API 和 DataStream 里都常见,好处是类型信息和 Python 函数绑定在一起,不会因为换个算子调用就丢:
from pyflink.table.udf import udf @udf(result_type=Types.ROW_NAMED(["id", "name"], [Types.INT(), Types.STRING()])) def parse(x): return Row(x[0], x[1])方式三,依赖 Python 注解。注意 PyFlink 的 type hints 支持是有限度的,你写def parse(x) -> Row:只能说明返回的是 Row,但 Row 里每个字段的类型它没法从注解里推导。所以对复合类型,注解只能帮你把大方向定住,字段级类型还是得靠前两种方式。
还有一种容易忽略的声明方式,是对 Source 本身做类型指定。env.from_collection可以传入第二个参数指定元素类型,不然 Source 也可能从第一天就把类型定歪,后面怎么补都别扭。
2.4 ROW/TUPLE 嵌套与类型构建
复合类型嵌套是免不了的。比如一条事件日志,外层是事件名和时间,内层是一个用户结构化对象,这种场景就很适合 ROW 套 ROW:
event_type = Types.ROW_NAMED(["event", "ts"], [Types.STRING(), Types.BIG_INT()]) payload_type = Types.ROW_NAMED(["user_id", "action"], [Types.STRING(), Types.STRING()]) whole_type = Types.ROW_NAMED(["header", "payload"], [event_type, payload_type])嵌套 TUPLE 也是一样,只是没有字段名,全看位置约定。我实际项目里的经验是:嵌套超过两层以后,尽量统一用 ROW_NAMED。因为嵌套结构最怕两个工程团队对字段顺序的理解不一致,一旦有一层没对齐,排查成本呈指数上升。有了字段名,至少报错的时候能直接告诉你是哪个字段出了问题。
这里还要提醒一个反模式:有人用 Python dict 直接当结构化数据返回,然后用Types.ROW_NAMED去声明。PyFlink 的 ROW 对象是有序的,dict 没有可靠顺序,声明类型后运行时字段值对不上,结果只会是数据错乱或序列化异常。别拿 dict 冒充 Row。
3. 类型声明对性能的影响:从“背着 pickle 跑”到“走专用通道”
3.1 序列化路径差异与开销对比
声明了类型和没声明类型,走的是两条完全不同的序列化路径。
没声明类型时,Python worker 里生成 pickle bytes,Java 端不解析、不拆分,整体当一个不透明对象。它的问题不只是“多花一点 CPU”,而是整个流不知道字段边界。做 shuffle 的时候,网络传输的数据量是 pickle 后的完整体积;做状态的时候,状态后端保存的也是 pickle 后的字节;做窗口触发的时候,窗口里缓存的全是这种大 blob。性能损失是乘数级的,不是加法级的。
声明了类型之后,比如Types.ROW_NAMED,PyFlink 会为这个 Row 生成一个对应的 Java 侧 TypeSerializer。Python worker 端也按 Row 的结构做编码,Java 端收到后直接按字段布局解析。数据更紧凑、CPU 开销更低,而且 Java 引擎知道每个字段的类型,后续的聚合、排序、分区都能利用字段级信息。
| 对比维度 | 未声明类型(Pickle) | 声明 ROW / TUPLE |
|---|---|---|
| 序列化产物 | 包含模块路径、属性名等冗余信息 | 紧凑的二进制字段布局 |
| Java 侧能否识别字段 | 不能,只有不透明 bytes | 能,TypeInformation 明确 |
| shuffle 网络负载 | 偏大,无法裁剪 | 更小,可借助二进制紧凑编码 |
| keyBy/分区 | 只能按整体 bytes 处理 | 可按具体字段处理 |
| Java 算子访问数据 | 几乎不可能 | 可以直接按强类型处理 |
有人会觉得,我没写类型,跑个小 demo 也没多慢。确实,本地几万条数据看不出差距。但一旦数据量上来,pickle 的 CPU 占用和 JVM 堆压力就会非常扎眼。我之前在一个百万级 QPS 的接入场景里做对比测试,去掉一个隐式 Pickle 后,吞吐直接翻了一倍多。当然这个数字依赖具体数据和集群规模,但方向是明确的:别再让管道扛着一堆无意义的 blob 跑。
3.2 一个简单 benchmark 思路
如果你想在自己环境里确认这个差距,可以做一个粗粒度的本地对比,不用上集群。
from pyflink.common import Types from pyflink.common.row import Row from pyflink.datastream import StreamExecutionEnvironment env = StreamExecutionEnvironment.get_execution_environment() env.set_parallelism(1) data = [(i, f"name_{i}") for i in range(10000)] def as_row(x): return Row(x[0], x[1]) source = env.from_collection(data, Types.TUPLE([Types.INT(), Types.STRING()])) # 不声明类型,走 pickle 路径 source.map(as_row).print() # 声明类型,走强类型路径 source.map(as_row, output_type=Types.ROW_NAMED(["id", "name"], [Types.INT(), Types.STRING()])).print() env.execute("type_benchmark")本地跑一次,观察 total throughput 或者干脆用系统监控看 Python worker 进程的 CPU。你会发现 pickle 路径的 Python CPU 占用明显偏高,因为 pickle.dumps 真的很耗。要注意,本地测试只能反映 CPU 趋势,真实集群里 shuffle 网络、状态后端的差异只会更大。所以做 benchmark 时别轻易说“没差别”,先确认数据量和并行度够不够。
3.3 提升性能的额外注意点
除了选对类型,还有几个提升性能的实操经验。
第一,尽量让数据在进入 Python UDF 之前就具备类型。Source 阶段就把类型定好,比 Python 端加工完再声明类型要稳。因为类型信息可以沿算子链向下传播,很多算子能直接复用上游序列化器,减少转换。
第二,如果只是做简单投影、过滤,优先考虑用 PyFlink Table API 或 SQL 表达。Table 层的优化和算子链合并能力比 DataStream 上硬写 Python UDF 强很多。Python UDF 适合复杂逻辑,不适合当搬运工。
第三,能显式用Types.PICKLE_BYTE_ARRAY()的时候,也尽量显式用。这个类型表达的意思是“我就是要在这个位置传一段 pickle bytes”,至少整个管道知道这里有一段不透明字节,不会在 Java 端再套一层莫名的处理。它比让 PyFlink 隐式兜底更可控,排查问题时也少一个变量。
第四,能用内置类型就用内置类型。PyFlink 的内置类型在 Java 和 Python 两侧都有固定映射,比如 STRING、INT、BIG_INT、ARRAY、MAP。自定义 Python 类不是不行,但等于把自己锁死在 pickle 通道里,跨语言、跨作业都痛苦。
4. Java 互操作的坑:类型没对齐,Java 侧接到的是 Pickle 包袱
4.1 PyFlink 与 Java DataStream 之间的类型通道
PyFlink 不是一个独立的 Python 流引擎,它是在 Java 进程中内嵌了 Python worker 的混合体。你在 Python 侧创建的 DataStream,底层对应一个 Java 侧 DataStream,也就是_j_data_stream。这个对象可以通过 Py4J 桥触达,意味着你可以在 Python 作业里调用 Java API,也可以把 Java 作业的 DataStream 拿给 Python 用。
但跨语言边界有一个隐藏前提:类型信息必须能穿过这个桥。Python 侧如果输出的是 Pickle bytes,Java 侧拿到的 TypeInformation 就只是一个不透明二进制类型。Java UDF 里想做强类型转换,等于去解析一个完全不透明的格式,只能失败。
反过来也一样,Java 侧处理完数据转回 Python,如果 Java 侧没有把数据转成 PyFlink 认识的标准类型,Python 侧拿到手的依然是个没法下手的对象。
所以类型声明在互操作里的角色不是“锦上添花”,而是“桥的基础设施”。
4.2 典型互操作场景与类型对齐
先看 Java 作业里调用 Python UDF 的场景。Java 侧最好先把元素转成 FlinkRow或TupleN,再进入 Python 算子。Python 侧用Types.ROW_NAMED或Types.TUPLE把接收结构定下来。两边字段顺序、字段名、类型必须完全一致。
# Python 侧定义好类型后,Java 侧才能拿到匹配的 Row ds = env.from_collection( [Row(1, "alice")], Types.ROW_NAMED(["id", "name"], [Types.INT(), Types.STRING()]) ) j_stream = ds._j_data_stream # 此时 j_stream 的 TypeInformation 是 RowTypeInfo, # Java 侧可以直接 map 成处理 Row 的函数。注意_j_data_stream属于底层内部属性,版本升级可能变动,生产上最好封装一层,别散落到各处。但理解这个通道,对排查互操作问题非常有帮助。只要类型对不上,你从这个通道拿 TypeInformation 看一眼,通常就能定位。
再看 Python 作业里调 Java UDF 的场景。Java UDF 如果是这样写的:map(Tuple2<String, Integer>),那 Python 侧数据进到 Java 前,必须先声明成Types.TUPLE([Types.STRING(), Types.INT()]),并且字段顺序要和 Java 的 f0、f1 对齐。一旦顺序反了,Java 端不会报明显错误,而是把字符串字段当成数值用,出现那种“数据看着对,算出来全是错的”诡异问题。
4.3 互操作时的其他常见坑
Java 泛型擦除是互操作里的大坑。Java 的泛型在运行时很多情况下会被擦除,Flink 如果没拿到 TypeHint,可能连 Java UDF 自身的返回类型都推断不出来。表现为运行时报 “could not determine type information”,让你怀疑是不是 PyFlink 写错了。解决办法是在 Java 侧显式.returns(new TypeHint<Tuple2<String, Integer>>(){}.getTypeInfo())或者.returns(RowTypeInfo)。
Row 的字段名大小写和顺序问题也很常见。Java 侧Row.getField("name")是大小写敏感的,Python 侧声明Types.ROW_NAMED(["name"], ...),Java 端就必须用name,用Name拿不到。跨系统协作时,最好定一套字段命名规范,统一小写加下划线,能省掉大量低级排错。
Java 侧如果习惯用 POJO,跨语言时不建议直接用。POJO 的字段布局和 Flink 的序列化机制复杂得多,PyFlink 没有义务把你的 Java POJO 自动转成 Python 里的 Row。稳妥做法是在 Java 侧加一个薄转换层,把 POJO 转成 Row 或 Tuple,再交给 Python。虽然多一次转换,但换来的是两边都能理解的稳定结构。
null 值处理同样要提前约定。Java 的 Tuple 里塞 null 在某些序列化路径下会玩出火,Row 则对字段级 null 更宽容。跨语言管道里,我会默认用 ROW_NAMED,因为它天生就带 schema 语义和 null 容忍度。
5. 常见问题与排查技巧实录
5.1 现场一:明明 map 写了类型,为什么日志里还有 Pickle?
这是被我同事问得最多的一个问题。代码里明明写了output_type=Types.ROW_NAMED(...),运行日志里还是能看到 pickle 字样。我用经验告诉你了:检查是不是中间还有别的算子没写类型。
比如你在 map 之后又加了一个filter,filter 的返回是 Python bool 或 tuple,没声明类型,那从 filter 往后,类型就又掉回 Pickle 了。还有连接操作,两个流连接成一个 FlatMap,如果只给其中一个输入声明了类型,另一个还是裸对象,整个算子链的序列化器依然会被拉回兜底方案。
排查手段很直接:在 Python 侧拿到底层 Java DataStream 的 TypeInformation,打印出来看。
j_type = ds._j_data_stream.getType() print(j_type)如果看到PickleSerializer或GenericType,说明这条流里有算子没有正确传递类型信息。从 Source 开始,每个算子逐个打印,通常很快就能锁定是谁把类型拖垮了。
5.2 现场二:Java 侧拿到 Row 后字段错位、字段名丢失
这种现场十有八九是用了Types.ROW而不是Types.ROW_NAMED。裸Types.ROW创建出来的 RowTypeInfo,字段名是 f0、f1 之类的占位符。你在 Python 侧写的是Row("alice", 18),Java 侧拿到的对象字段名却是 f0、f1,不是 age、name。
解决方法是统一用Types.ROW_NAMED,并且字段名顺序和 Java 侧保持一致。另外要注意,Row 不是 Map,字段名的匹配是精确的,你不能依赖“名字差不多就能匹配上”。你在 Python 里容易犯的错就是把 Row 当 dict 用,访问一个不存在的字段名,然后得到空值。这里没有什么默认容错,全是静默错误,等到下游数据对账时才炸出来。
5.3 现场三:Tuple 无法直接转 Table,需要字段名
有人用 TUPLE 跑通了 DataStream 业务,等到要接 Table API 做统计时,发现视图创建失败,报错说没有 schema。原因就是 TUPLE 没有字段名,Table 需要的列名无法从类型系统里拿。
处理办法有两种。第一种,直接把类型改成ROW_NAMED,这是最省事的。第二种,如果 TUPLE 已经用了很久,不想动上游逻辑,可以在from_data_stream注册表的时候补 schema:
from pyflink.table import StreamTableEnvironment, Schema s_env = StreamTableEnvironment.create(env) schema = Schema.new_builder() \ .column("id", "INT") \ .column("name", "STRING") \ .build() table = s_env.from_data_stream(tuple_stream, schema)这不难,但我要提醒的是:临时补 schema 可以用,长期设计里还是把类型契约从一开始定清楚。每次在管道中间补 schema,都是在给未来埋雷。
5.4 快速排查 Checklist
我整理了一个日常自检清单,每次遇到 PyFlink 类型问题就按这个顺序过一遍:
- 所有 Python UDF 是否都有明确输出类型?包括匿名 map、filter、flat_map,一个都不能漏。
- 是否误用了无字段名的裸
Types.ROW?需要字段名就用ROW_NAMED。 - 跨语言边界是否统一使用 Row 或 Tuple 标准类型?有没有自定义 Python 类在偷偷走 pickle。
- Java 侧的泛型擦除是否补齐了 TypeInformation?例如
returns(new TypeHint<...>(){})。 - Row 字段名、顺序、大小写是否在 Python 和 Java 两侧保持完全一致。
- Source 阶段是否已经把元素类型定好?不要让类型系统从源头就糊涂。
这六条检查完,大部分类型相关灵异事件都能定位。剩下的少数问题,就需要打开底层 TypeInformation 一层层打印,看是哪一个算子中途丢的类型。
我个人在实际项目里有一条很朴素的标准:所有 Python UDF 的出口,要么是基础类型,要么是ROW_NAMED;只有纯内部临时打包才用TUPLE;所有跨语言边界统一走 ROW。这套约定执行了半年,PyFlink 侧的类型问题基本绝迹。最后再分享一个实操技巧,排查类型问题时别只看 Python 侧日志,直接打印 Xml_j_data_stream.getType(),把 Java 侧 TypeInformation 拉出来看一眼,胜过在日志里猜半天。类型很重要,但更重要的是把它当成系统设计的一部分,而不是事后补救的补丁。