
1. 双流Join的痛点与Union All方案的切入逻辑做实时数仓的同学大概率都遇到过这个场景两条业务流一条是订单主表变更流一条是订单明细变更流业务方要求实时输出订单明细的宽表结果。直觉上第一反应肯定是Flink SQL的Regular Join或者Interval Join写起来也就几行代码的事。但真到生产环境跑起来问题就来了——状态无限膨胀、Checkpoint越来越大、作业跑几天就OOM这些都是双流Join的经典坑。我在某次实时宽表项目中就踩过这个坑。当时两条流的数据量都不小订单主流QPS大概在八千左右明细流峰值能到两万多。用Regular Join跑了两天状态后端从最初的几百MB涨到了十几个GBCheckpoint时间从3秒涨到40多秒最后作业直接反压到Source端。后来换成了Union All加聚合的思路状态直接降到了原来的十分之一作业稳定跑了半年多没出过问题。这篇文章就把这套Union All实现双流Join的方案完整拆一遍。核心思路其实不复杂把两条流打上不同的标记Union成一条流然后按关联键做KeyBy用状态存储两侧的数据等另一侧数据到达时做匹配输出。听起来像是把Join的活自己手动干了一遍但正是这种手动挡的方式让我们能精确控制状态的TTL、清理策略和触发时机避免框架自动管理状态时那种只管进不管出的问题。适合谁来参考如果你正在做实时数仓、实时风控、实时对账这类需要双流关联的场景并且已经被状态膨胀折磨过那这套方案值得你花时间研究。如果你只是写个Demo玩玩那用原生Join就够了没必要上这套。下面我会从设计思路、核心实现、参数调优到问题排查一层层拆开讲。2. 为什么原生双流Join会状态爆炸2.1 Regular Join的状态保留机制先搞清楚原生Join为什么吃状态。Flink SQL的Regular Join也就是普通的A JOIN B在底层会把两条流的数据都存到状态里而且默认情况下永不过期。什么意思呢就是左流来一条数据它会把这条数据存到左状态里等着右流的数据来匹配右流来一条同样存到右状态里。如果一条数据一直等不到匹配的另一侧它就会永远躺在状态里。这个设计在离线场景下没问题因为数据是有限的。但在实时场景下两条流的数据都是无界流状态只会越积越多。官方文档里其实提到了可以配置table.exec.state.ttl来设置状态过期时间但很多人不知道这个参数或者设了之后发现业务上又出现迟到的数据匹配不上的问题。2.2 Interval Join的时间窗口限制有人会说那用Interval Join啊它有时间窗口限制状态不会无限增长。没错Interval Join确实会基于时间区间清理状态比如你设置INTERVAL 1 HOUR那它只保留一小时内的数据。但问题是业务上两条流的到达时间差往往是不确定的。订单创建后明细可能5秒就到了也可能因为上游系统延迟5分钟后才到。如果你把窗口设小了迟到的明细就匹配不上设大了状态又下不来。这个平衡点非常难找。而且Interval Join还有个限制它要求两条流都有可靠的事件时间戳并且水位线要正常推进。如果某条流出现数据倾斜或者某个分区长时间没数据水位线卡住状态照样清不掉。2.3 Union All方案的核心优势Union All方案的本质是把Join的控制权从框架手里拿回来。具体来说状态可控我们可以自己决定状态存多久用ValueState的TTL或者定时器来清理而不是依赖框架的全局配置。触发灵活可以支持左流先到等右流和右流先到等左流两种模式甚至可以做多路关联。调试方便每条数据在状态里的存取逻辑都是我们自己写的出问题的时候能精确定位是哪一步出了岔子。资源隔离两条流的状态可以分别设置不同的过期策略比如主表数据保留久一点明细表数据保留短一点。代价是什么代码量比写一行SQL多得多而且需要自己处理乱序、去重、状态清理这些细节。但对于生产级的实时宽表来说这点代码量换来的稳定性是完全值得的。3. Union All双流Join的整体设计拆解3.1 数据流拓扑设计整个方案的数据流是这样的订单主流 -- 打标(tagorder) -- \ Union -- KeyBy(orderId) -- 状态匹配 -- 输出宽表 订单明细流 -- 打标(tagdetail) -- /关键点在于打标这一步。两条流的数据结构可能完全不同我们需要把它们统一成一种中间格式。通常有两种做法第一种是用一个统一的POJO包含所有可能用到的字段另一侧没有的字段留空。这种做法的好处是类型安全坏处是字段多了之后类会很臃肿。第二种是用Either或者自定义的包装类把原始数据包在里面再加一个tag字段标识来源。这种做法更灵活但取值的时候需要做类型判断。我一般推荐第一种因为实时宽表场景下两条流的字段往往有重叠统一成宽表结构反而更自然。比如订单主流有orderId、userId、amount、status明细流有orderId、productId、quantity、price那中间格式就可以是包含所有这些字段的一个类哪条流来了就填哪部分。3.2 状态存储结构设计状态这块是整个方案的核心。我们需要为每个orderId维护两份状态主流状态存储该订单的主流数据等明细来匹配。明细状态存储该订单的明细数据等主流来匹配。这里有个关键决策明细状态用ListState还是ValueState因为一个订单可能对应多条明细所以明细侧必须用ListState来存多条记录。而主流侧一个订单通常只有一条记录除非有更新用ValueState就够了。但实际业务中订单主流也可能有多次变更比如状态从待支付变成已支付这时候主流侧也需要用ListState或者允许覆盖的ValueState。我的建议是如果业务只关心最新状态用ValueState并在每次更新时覆盖如果需要保留变更历史那就用ListState。3.3 匹配触发时机匹配逻辑分两种情况情况一主流先到明细后到。主流数据到达时先存到主流状态然后去查明细状态。如果明细状态里有数据说明这个订单的明细已经先到了那就直接做关联输出。如果明细状态为空就注册一个定时器等一段时间后如果还没等到明细可以选择输出左外连接的结果或者直接丢弃。情况二明细先到主流后到。明细数据到达时存到明细状态然后去查主流状态。如果主流状态有数据直接关联输出如果没有同样注册定时器等待。这里有个细节需要注意如果主流和明细都到了但后续主流又更新了怎么办这时候需要重新触发关联把最新的主流数据和已存在的明细数据再关联一次输出更新后的宽表。这个逻辑在实时数仓里很常见因为订单状态是会变的。4. 核心代码实现与关键细节4.1 统一数据模型定义先定义一个中间POJO包含两条流的所有字段public class OrderWideBean { public String orderId; public String userId; public Double amount; public String status; public String productId; public Integer quantity; public Double price; public String tag; // order 或 detail public Long eventTime; public static OrderWideBean fromOrder(Order order) { OrderWideBean bean new OrderWideBean(); bean.orderId order.getOrderId(); bean.userId order.getUserId(); bean.amount order.getAmount(); bean.status order.getStatus(); bean.tag order; bean.eventTime order.getEventTime(); return bean; } public static OrderWideBean fromDetail(OrderDetail detail) { OrderWideBean bean new OrderWideBean(); bean.orderId detail.getOrderId(); bean.productId detail.getProductId(); bean.quantity detail.getQuantity(); bean.price detail.getPrice(); bean.tag detail; bean.eventTime detail.getEventTime(); return bean; } }这个类看起来简单但有几个坑要注意。第一所有字段必须是public或者有getter/setter否则Flink的序列化器可能识别不了。第二eventTime字段一定要有后面注册定时器的时候要用。第三tag字段用来区分数据来源在ProcessFunction里做分支判断。4.2 打标与Union操作DataStreamOrderWideBean orderStream env.addSource(new OrderSource()) .map(OrderWideBean::fromOrder) .assignTimestampsAndWatermarks( WatermarkStrategy.OrderWideBeanforBoundedOutOfOrderness(Duration.ofSeconds(5)) .withTimestampAssigner((bean, ts) - bean.eventTime) ); DataStreamOrderWideBean detailStream env.addSource(new DetailSource()) .map(OrderWideBean::fromDetail) .assignTimestampsAndWatermarks( WatermarkStrategy.OrderWideBeanforBoundedOutOfOrderness(Duration.ofSeconds(5)) .withTimestampAssigner((bean, ts) - bean.eventTime) ); DataStreamOrderWideBean unionStream orderStream.union(detailStream);这里有个关键点两条流的水位线策略要一致。如果一条流用forBoundedOutOfOrderness另一条用forMonotonousTimestampsUnion之后的水位线会取两条流的最小值可能导致水位线推进过慢。我一般统一用forBoundedOutOfOrderness乱序容忍度根据业务延迟情况设置通常5到10秒。另外Union之前一定要先做KeyBy吗不需要。Union只是把两条流合并成一条不涉及状态操作所以可以在Union之后再KeyBy。但要注意Union要求两条流的类型完全一致所以打标这一步必须在Union之前完成。4.3 状态匹配核心逻辑这是整个方案最核心的部分用KeyedCoProcessFunction来实现public class OrderJoinFunction extends KeyedCoProcessFunctionString, OrderWideBean, OrderWideBean, OrderWideBean { private ValueStateOrderWideBean orderState; private ListStateOrderWideBean detailState; private ValueStateLong timerState; Override public void open(Configuration parameters) { ValueStateDescriptorOrderWideBean orderDesc new ValueStateDescriptor(orderState, OrderWideBean.class); orderDesc.enableTimeToLive(StateTtlConfig.newBuilder(Time.hours(24)) .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite) .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired) .build()); orderState getRuntimeContext().getState(orderDesc); ListStateDescriptorOrderWideBean detailDesc new ListStateDescriptor(detailState, OrderWideBean.class); detailDesc.enableTimeToLive(StateTtlConfig.newBuilder(Time.hours(24)) .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite) .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired) .build()); detailState getRuntimeContext().getListState(detailDesc); timerState getRuntimeContext().getState( new ValueStateDescriptor(timerState, Long.class)); } Override public void processElement1(OrderWideBean order, Context ctx, CollectorOrderWideBean out) throws Exception { // 主流数据到达 orderState.update(order); // 检查明细状态是否有数据 IterableOrderWideBean details detailState.get(); boolean hasDetail false; for (OrderWideBean detail : details) { hasDetail true; out.collect(merge(order, detail)); } // 如果没有明细注册定时器等待 if (!hasDetail) { long timerTs ctx.timestamp() 300000; // 5分钟 ctx.timerService().registerEventTimeTimer(timerTs); timerState.update(timerTs); } } Override public void processElement2(OrderWideBean detail, Context ctx, CollectorOrderWideBean out) throws Exception { // 明细数据到达 detailState.add(detail); // 检查主流状态是否有数据 OrderWideBean order orderState.value(); if (order ! null) { out.collect(merge(order, detail)); } } Override public void onTimer(long timestamp, OnTimerContext ctx, CollectorOrderWideBean out) throws Exception { // 定时器触发说明等待超时 Long timerTs timerState.value(); if (timerTs ! null timerTs timestamp) { OrderWideBean order orderState.value(); if (order ! null) { // 输出左外连接结果明细字段为空 out.collect(order); } timerState.clear(); } } private OrderWideBean merge(OrderWideBean order, OrderWideBean detail) { OrderWideBean result new OrderWideBean(); result.orderId order.orderId; result.userId order.userId; result.amount order.amount; result.status order.status; result.productId detail.productId; result.quantity detail.quantity; result.price detail.price; result.eventTime Math.max(order.eventTime, detail.eventTime); return result; } }这段代码有几个关键设计点需要展开说。第一状态TTL的设置。我给两个状态都设置了24小时的TTL更新类型是OnCreateAndWrite意思是每次写入或更新时刷新过期时间。NeverReturnExpired表示过期数据不会被返回。这个TTL要根据业务的数据延迟情况来定如果业务上明细最晚可能第二天才到那TTL就得设48小时。但TTL越长状态越大这是个权衡。第二定时器的使用。主流到达但没有明细时注册一个5分钟后的定时器。如果5分钟内明细到了processElement2里会直接输出关联结果但定时器不会自动取消。所以我在onTimer里做了判断只有当timerState里的时间戳和当前触发的时间戳一致时才输出。这样可以避免重复输出。第三明细状态的遍历。一个订单可能有多条明细所以processElement1里需要遍历detailState每条明细都和主流做一次关联输出多条宽表记录。这里要注意如果明细数据量很大比如一个订单有上千条明细遍历可能会成为性能瓶颈。这种情况下可以考虑限制明细数量或者改用其他方案。4.4 主流更新时的重新关联上面的代码有个问题如果主流数据更新了比如订单状态从待支付变成已支付orderState.update(order)会覆盖旧状态但已经输出的宽表不会自动更新。如果业务需要输出更新后的宽表需要在processElement1里加一段逻辑// 主流更新时重新关联已存在的明细 IterableOrderWideBean details detailState.get(); for (OrderWideBean detail : details) { out.collect(merge(order, detail)); }但这样会导致下游收到重复的宽表记录。解决办法是在输出时带上一个版本号或者时间戳下游根据版本号做去重。或者用Flink SQL的upsert-kafka连接器把结果写成changelog流。5. 参数调优与状态管理实战5.1 状态后端选型对比Union All方案的状态量虽然比原生Join小但依然不容忽视。状态后端的选择直接影响作业的稳定性和性能。目前主流的有三种状态后端适用场景优点缺点HashMapStateBackend状态量小、本地测试读写快无序列化开销状态受限于TaskManager内存EmbeddedRocksDBStateBackend状态量大、生产环境支持超大状态增量Checkpoint读写有序列化开销性能略低混合方案热数据内存、冷数据RocksDB兼顾性能和容量配置复杂需要自己实现我的经验是如果单Key状态能控制在1MB以内总状态在10GB以下用HashMapStateBackend就够了。超过这个量级老老实实上RocksDB。RocksDB的增量Checkpoint能大幅减少Checkpoint时间但要注意配置好state.backend.rocksdb.memory.managed和state.backend.rocksdb.block.cache-size否则容易OOM。5.2 Checkpoint配置要点Checkpoint配置直接关系到作业的容错能力和稳定性。几个关键参数execution.checkpointing.interval: 3min execution.checkpointing.timeout: 10min execution.checkpointing.min-pause: 1min execution.checkpointing.max-concurrent-checkpoints: 1 execution.checkpointing.tolerable-failed-checkpoints: 3 state.backend.incremental: trueinterval设3分钟是个比较平衡的值。设太短Checkpoint频繁影响吞吐设太长故障恢复时丢失的数据多。timeout要设得比interval大给Checkpoint足够的时间完成。min-pause保证两次Checkpoint之间有间隔避免连续触发。max-concurrent-checkpoints设1避免并发Checkpoint争抢资源。state.backend.incremental开启增量Checkpoint对RocksDB状态后端特别有效能把Checkpoint时间从分钟级降到秒级。5.3 状态TTL的精细控制前面代码里用了StateTtlConfig来设置状态过期但这里有几个坑坑一TTL只对读操作生效。状态过期后并不是立即从磁盘删除而是在下一次读操作时才会被过滤掉。所以如果某个Key长时间不访问它的状态会一直占着磁盘。解决办法是配合定时器在定时器里主动clear()状态。坑二ListState的TTL是整体过期。如果ListState里存了多条明细TTL是针对整个List的不是针对单条明细的。只要List被更新过所有明细的过期时间都会刷新。如果业务上明细是分批到达的可能会导致早期明细一直不过期。坑三TTL时间要大于业务最大延迟。如果业务上明细最晚可能第二天才到TTL设24小时就可能导致明细到达时主流状态已经过期了。这时候要么加大TTL要么在主流过期前输出左外连接结果。我的建议是TTL设置成业务最大延迟的1.5倍并且配合定时器做主动清理。比如业务最大延迟是2小时TTL设3小时定时器设2.5小时。这样既能保证数据不丢又能及时释放状态。6. 常见问题排查与避坑指南6.1 数据倾斜导致的反压双流Join最容易出现的问题就是数据倾斜。如果某个orderId的明细特别多比如热门商品的订单这个Key对应的状态就会特别大处理时间也会特别长导致整个作业反压。排查方法在Flink Web UI的Records Sent和Records Received指标里看各个SubTask的差异如果某个SubTask的吞吐量明显低于其他基本就是倾斜了。解决办法有几种一是加盐打散给orderId加随机前缀把热点Key分散到多个SubTask但这样会导致同一个订单的数据落到不同SubTask需要二次聚合。二是限制单Key明细数量超过阈值后直接丢弃或者写到旁路输出。三是预聚合在明细流进入Join之前先按orderId做一次聚合减少明细数量。6.2 水位线卡住导致定时器不触发定时器是基于事件时间的如果水位线不推进定时器永远不会触发。水位线卡住的常见原因有某个分区长时间没有数据水位线取所有分区的最小值导致整体卡住。数据乱序严重forBoundedOutOfOrderness的容忍度设得太小水位线推进缓慢。Source端并行度大于分区数某些Source SubTask空闲水位线不推进。排查方法在Web UI的Watermarks指标里看各个SubTask的水位线值如果某个SubTask的水位线明显落后就是它拖后腿了。解决办法对于空闲分区可以设置withIdleness让空闲分区不参与水位线计算WatermarkStrategy.OrderWideBeanforBoundedOutOfOrderness(Duration.ofSeconds(5)) .withIdleness(Duration.ofMinutes(1)) .withTimestampAssigner((bean, ts) - bean.eventTime)6.3 状态序列化失败自定义POJO作为状态类型时如果没实现Serializable接口或者字段类型不支持序列化作业启动时就会报错。常见的错误信息是org.apache.flink.api.common.InvalidProgramException: The implementation of the ... is not serializable。解决办法确保POJO实现Serializable所有字段都是可序列化的类型。如果字段里有Object类型最好改成具体类型。另外如果POJO里有List或Map要确保它们的具体实现类也是可序列化的。6.4 常见问题速查表问题现象可能原因排查方法解决方案作业反压吞吐下降数据倾斜查看各SubTask吞吐量加盐打散或预聚合定时器不触发水位线卡住查看Watermarks指标设置withIdlenessCheckpoint超时状态太大查看Checkpoint大小开启增量Checkpoint调小TTL状态序列化失败POJO未实现Serializable查看启动日志实现Serializable接口关联结果重复主流更新触发重新关联查看输出日志加版本号下游去重明细匹配不上TTL过期或水位线问题查看状态大小和延迟调大TTL检查水位线6.5 独家避坑技巧技巧一用侧输出流处理迟到数据。如果明细数据超过了TTL才到达主流状态已经过期这时候可以把迟到数据写到侧输出流后续用离线任务补上。这样既不丢数据又不影响主流程。技巧二状态大小监控。在open方法里注册一个定时器每隔一段时间打印一次状态大小。或者用Flink的Metrics系统自定义一个Gauge来暴露状态大小。这样能提前发现状态膨胀的趋势。技巧三灰度上线。新方案上线时先和原生Join方案并行跑一段时间对比两边的输出结果。如果一致再切流量。这样能避免因为逻辑bug导致的数据错误。技巧四KeyBy之前先过滤。如果两条流里有大量orderId为空的数据这些数据KeyBy之后会落到同一个SubTask造成严重倾斜。所以在KeyBy之前先过滤掉orderId为空的数据。7. 方案扩展与性能优化方向7.1 多流关联的扩展Union All方案不仅支持双流还能扩展到多流。比如订单主流、明细流、支付流三流关联只需要在Union的时候打上三种tag在ProcessFunction里维护三份状态匹配逻辑稍微复杂一点但整体思路是一样的。不过要注意流越多状态越大匹配逻辑越复杂。三流关联的状态量大概是双流的1.5倍四流就是2倍。所以扩展之前先评估一下状态容量。7.2 异步IO优化关联性能如果关联的维度数据在外部存储比如HBase、Redis可以用异步IO来查询避免同步查询阻塞主流程。Flink的AsyncDataStream支持异步查询能大幅提升吞吐量。但要注意设置好超时时间和并发度避免把外部存储打挂。7.3 状态压缩减少存储如果状态里的数据有大量重复字段可以考虑用压缩算法减少存储。比如把POJO序列化成JSON后再压缩或者用Protobuf这种高效的序列化格式。RocksDB本身支持Snappy和LZ4压缩配置state.backend.rocksdb.compression就能开启。7.4 动态调整TTL业务的数据延迟不是一成不变的大促期间延迟可能比平时高很多。如果TTL设死了大促期间就容易丢数据。可以考虑做一个动态TTL根据当前的水位线延迟自动调整TTL时间。比如水位线延迟超过1小时就把TTL从24小时调到48小时。这个需要自己实现但思路不复杂。8. 我个人在实际操作中的几点体会这套Union All方案我在三个项目里用过最大的感受是它把复杂度从框架转移到了业务代码但换来了可控性和稳定性。原生Join写起来爽但出了问题很难排查因为状态管理是黑盒。Union All方案虽然代码多但每一步都是透明的出问题能精确定位。另外一点体会是状态TTL的设置比想象中重要。我见过太多作业因为TTL设得太大导致状态爆炸也见过TTL设得太小导致数据丢失。我的经验是TTL设置成业务最大延迟的1.5倍并且一定要配合定时器做主动清理。定时器的时间可以比TTL稍短这样能在状态过期前主动释放。最后分享一个小技巧在ProcessFunction里加一个计数器统计匹配成功和匹配失败的数量。这个计数器可以通过Flink的Metrics系统暴露出来在Grafana上做监控。如果匹配失败率突然升高说明要么是数据延迟变大了要么是TTL设小了能第一时间发现问题。这套方案后续还可以往两个方向扩展一是接入Flink SQL把ProcessFunction封装成UDF这样既能享受SQL的便利又能控制状态二是结合CDC把状态变更实时同步到下游实现真正的实时宽表更新。这两个方向我都在探索中有进展再分享。