一、 那个大促,老板盯着"不动"的大屏
讲个真实场景。

去年双11,老板在作战指挥室盯着大屏,全场气氛紧张。大屏上的"实时成交额"数字在零点刚过时跳动了几下,然后——卡住了整整8分钟。

8分钟后数字突然跳了一大截,老板脸色铁青:“这大屏有什么用?还不如excel刷新快!”

问题出在哪?我们当时的大屏是用定时任务+轮询实现的:每30秒从数据库查一次聚合数据,然后刷新前端展示。在大促流量高峰下,数据库压力巨大,定时任务执行时间从30秒被拉长到10分钟。

那一刻我们意识到:传统的"定时查库"方案在大促场景下完全失效。真正的实时大屏,需要流式计算架构。

本文将完整复盘我们如何从定时任务轮询演进到Flink+Kafka实时计算的过程,重点分享架构设计、数据准确性保障以及大屏性能优化的实战经验。

二、 什么是"实时大屏"?和传统BI报表有什么区别?
先厘清概念:

维度 传统BI报表 实时大屏
数据时效 T+1(隔天看昨天数据) 秒级/毫秒级(当前这一刻)
数据来源 离线数仓(Hive/数仓) 实时消息流(Kafka) + 实时计算(Flink)
查询方式 定时SQL查询数据库 流式聚合,预先计算好结果
更新频率 每小时/每天 每秒刷新
典型场景 月度经营报表、季度复盘 大促作战指挥室、运营实时监控
一句话区别:BI报表问"昨天发生了什么",实时大屏告诉你"现在正在发生什么"。

三、 技术选型:实时计算引擎的对比
我们对比了市面上主流的实时计算方案:

方案 优点 缺点 结论
Flink 真·流处理、毫秒级延迟、状态管理强大、Exactly-Once语义 学习曲线陡峭、调优复杂 ✅ 首选
Spark Streaming 生态成熟、易上手、与Spark生态无缝集成 微批次架构(延迟秒级)、非真正流处理 ⚠️ 备选
Kafka Streams 轻量级、无需额外集群 功能相对简单、不适合复杂计算 ❌ 不适合大屏场景
自研(定时任务) 实现简单 延迟高、无法应对高峰 ❌ 已被淘汰
最终选择:Flink 1.15 + Kafka 3.0 + Redis(结果缓存) + WebSocket(推送前端)

选型理由:

Flink的事件时间(Event Time) 机制能处理乱序数据,对电商场景(订单可能延迟上报)至关重要。

Flink的状态后端(RocksDB) 支持TB级状态存储,可以做大窗口聚合(如近1小时、近24小时)。

支持Exactly-Once语义,确保大屏数据不重不漏。

四、 整体架构:从数据产生到前端展示
text
[用户行为]
│ (下单/支付/加购/浏览)

[业务系统]
│ 写入MySQL + 发送MQ消息

[Kafka] (Topic: order-topic, pay-topic, cart-topic, view-topic)
│ (分区数 = 16,保证顺序性和并行度)

[Flink实时计算]
│ ├─ 订单流:聚合实时成交额、订单量
│ ├─ 支付流:聚合支付金额、支付成功率
│ ├─ 加购流:聚合加购件数、加购人数
│ └─ 浏览流:聚合PV、UV

[Redis] (结果缓存, Key: 统计维度)
│ (存储各维度聚合结果,供查询)

[WebSocket推送]
│ (后端主动推送数据变化到前端)

[前端大屏] (Vue + ECharts)
│ (每秒刷新,动画平滑)
└─ 实时展示:成交额、订单量、热销榜单、流量趋势
五、 Flink核心实现:实时聚合的四种模式
5.1 模式一:滚动窗口(Tumbling Window)——“每5秒统计一次”
场景:大屏上的"近5秒成交额"实时趋势图。

java
// 滚动窗口:每5秒一个窗口,窗口之间不重叠
DataStream orderStream = env.addSource(new FlinkKafkaConsumer<>(“order-topic”, …));

DataStream resultStream = orderStream
.keyBy(Order::getChannel) // 按渠道分组(APP/H5/小程序)
.window(TumblingProcessingTimeWindows.of(Time.seconds(5)))
.aggregate(new OrderAggregateFunction(), new OrderWindowFunction());

// 聚合函数
public class OrderAggregateFunction implements AggregateFunction<Order, OrderAccumulator, Double> {
@Override
public OrderAccumulator createAccumulator() {
return new OrderAccumulator(0.0, 0L);
}

@Override
public OrderAccumulator add(Order order, OrderAccumulator acc) {
    acc.setTotalAmount(acc.getTotalAmount() + order.getAmount());
    acc.setCount(acc.getCount() + 1);
    return acc;
}

@Override
public Double getResult(OrderAccumulator acc) {
    return acc.getTotalAmount();  // 输出窗口成交总额
}

@Override
public OrderAccumulator merge(OrderAccumulator a, OrderAccumulator b) {
    return new OrderAccumulator(a.getTotalAmount() + b.getTotalAmount(), 
                                a.getCount() + b.getCount());
}

}
5.2 模式二:滑动窗口(Sliding Window)——“每1秒统计近1分钟”
场景:大屏顶部的"近1分钟成交额"实时数据。

java
// 滑动窗口:窗口长度60秒,每1秒滑动一次
DataStream resultStream = orderStream
.keyBy(Order::getChannel)
.window(SlidingProcessingTimeWindows.of(Time.seconds(60), Time.seconds(1)))
.aggregate(new OrderAggregateFunction(), new OrderWindowFunction());
注意:滑动窗口会产生大量重叠窗口(60个窗口同时存在),对内存消耗大。需要合理设置状态后端为RocksDB。

5.3 模式三:会话窗口(Session Window)——“用户一次访问会话”
场景:统计用户的"访问深度"(一次会话中浏览了多少商品)。

java
// 会话窗口:用户无操作超过30分钟,会话结束
DataStream sessionStream = viewStream
.keyBy(ViewLog::getUserId)
.window(ProcessingTimeSessionWindows.withGap(Time.minutes(30)))
.aggregate(new SessionAggregateFunction());
5.4 模式四:实时TopN——“热销商品排行榜”
场景:大屏侧边的"实时热销商品Top10"。

java
// 实现思路:滚动窗口聚合 + 排序
DataStream topNStream = orderStream
.keyBy(Order::getSkuId) // 按商品ID分组
.window(TumblingProcessingTimeWindows.of(Time.seconds(10)))
.aggregate(new SalesAggregateFunction()) // 聚合每个商品的销量
.keyBy(SalesResult::getWindowEnd) // 按窗口结束时间分组
.window(TumblingProcessingTimeWindows.of(Time.seconds(10)))
.process(new TopNFunction(10)); // 取Top10

// TopN处理函数
public class TopNFunction extends KeyedProcessFunction<Long, SalesResult, TopNResult> {
private final int topSize;
private transient ListState listState;

public TopNFunction(int topSize) {
    this.topSize = topSize;
}

@Override
public void open(Configuration parameters) throws Exception {
    super.open(parameters);
    ListStateDescriptor<SalesResult> descriptor = 
        new ListStateDescriptor<>("topn-state", SalesResult.class);
    listState = getRuntimeContext().getListState(descriptor);
}

@Override
public void processElement(SalesResult value, Context ctx, Collector<TopNResult> out) throws Exception {
    listState.add(value);
    
    // 注册定时器,窗口结束时触发排序
    ctx.timerService().registerProcessingTimeTimer(ctx.getCurrentKey());
}

@Override
public void onTimer(long timestamp, OnTimerContext ctx, Collector<TopNResult> out) throws Exception {
    List<SalesResult> all = new ArrayList<>();
    listState.get().forEach(all::add);
    
    // 按销量排序,取TopN
    all.sort((a, b) -> b.getSaleCount().compareTo(a.getSaleCount()));
    List<SalesResult> topN = all.stream().limit(topSize).collect(Collectors.toList());
    
    out.collect(new TopNResult(timestamp, topN));
    listState.clear();
}

}
六、 数据准确性保障:Exactly-Once语义
大屏数据如果"不准",比"不实时"更致命。老板不会接受"显示成交额1个亿,实际只有8000万"。

6.1 Flink的Exactly-Once如何实现?
text
[Kafka Source]
│ ① 消费消息时,保存offset到checkpoint

[Flink 算子]
│ ② 算子内部状态(聚合结果)也保存到checkpoint

[Kafka Sink / Redis Sink]
│ ③ 只有在checkpoint完成时才提交写入
└─ 如果任务失败,从最近的checkpoint恢复,offset和状态完全一致
关键配置:

java
// 启用Checkpoint
env.enableCheckpointing(30000); // 每30秒做一次Checkpoint
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(10000);
env.getCheckpointConfig().setCheckpointTimeout(60000);
env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);
6.2 去重机制:幂等写入Redis
即使Flink保证了Exactly-Once,写入Redis时仍可能因为网络重试导致重复写入。

解决方案:Redis写入幂等性

java
// 使用Redis的SETNX命令实现幂等
public void saveToRedis(String key, String value, String checkpointId) {
String uniqueKey = key + “:” + checkpointId;
// SETNX:如果key不存在才设置,存在则不操作
Boolean success = redis.setnx(uniqueKey, “1”, 3600);
if (success) {
// 只有首次成功才写入实际数据
redis.setex(key, 60, value);
}
}
6.3 端到端数据对账
每天凌晨运行对账任务,比对Flink计算结果和离线数仓的结果:

sql
– 离线数仓:T+1的准确数据
SELECT SUM(amount) FROM dwd_order WHERE dt = ‘2024-01-01’;

– Flink实时结果:从Redis读取昨天的累计值
GET “total_amount:2024-01-01”
差异容忍度:小于0.1% 视为正常(实时计算本身存在时间窗口边界误差),超过则触发告警人工介入。

七、 大屏性能优化:如何做到"丝滑"刷新?
7.1 问题:前端每秒刷新,后端扛不住
场景:大屏前端每秒发一个HTTP请求拉取最新数据,100个大屏同时打开时,后端QPS直接飙到100+,Redis压力巨大。

解决方案:WebSocket主动推送

javascript
// 前端:建立WebSocket连接
const ws = new WebSocket(‘ws://dashboard-server/ws’);

ws.onmessage = function(event) {
const data = JSON.parse(event.data);
// 更新ECharts图表
updateChart(data);
};

// 后端:Flink计算结果推送到WebSocket服务
@Component
public class DashboardPushService {

@Scheduled(fixedDelay = 1000)  // 每秒推送一次
public void pushToClients() {
    // 从Redis读取最新聚合结果
    Map<String, Object> data = redisClient.hgetAll("dashboard:latest");
    
    // 广播给所有连接的客户端
    for (WebSocketSession session : sessions) {
        session.sendMessage(new TextMessage(JSON.toJSONString(data)));
    }
}

}
优化效果:HTTP请求从每秒100次降为0(改为服务端主动推送),Redis压力大幅降低。

7.2 问题:ECharts渲染卡顿
场景:实时趋势图每秒更新一个数据点,10分钟后图表上就有600个点,重绘时明显卡顿。

解决方案:数据采样 + 增量更新

javascript
// 1. 数据采样:只保留最近100个点
if (chartData.length > 100) {
chartData.shift(); // 移除最早的点
}

// 2. 增量更新:不重绘整个图表,只追加新数据
myChart.appendData({
seriesIndex: 0,
data: [newValue]
});
7.3 问题:多个大屏同时展示,数据不一致
场景:老板办公室的大屏和运营部的大屏显示的"实时成交额"不一样。

原因:两个大屏可能连接到了不同的WebSocket服务节点,Redis读取时出现短暂延迟。

解决方案:统一数据源 + 版本号机制

java
// Redis存储数据时带上版本号(时间戳)
redis.setex(“dashboard:total_amount”, 60,
JSON.toJSONString(new DataWrapper(value, System.currentTimeMillis())));

// 前端比对版本号,只更新更新的数据
if (receivedData.version > currentVersion) {
updateChart(receivedData.value);
currentVersion = receivedData.version;
}
八、 踩坑实录:Flink生产环境的"三大天坑"
坑1:反压(Backpressure)导致任务卡死
现象:大促期间,订单消息量暴增,Flink任务处理不过来,Kafka消费Lag从100暴涨到100万,最终任务崩溃。

原因:下游Redis写入成为瓶颈(单节点Redis写入TPS有限)。

解决方案:

增加并行度:将Flink算子的并行度从4提升到16。

java
// 设置并行度
env.setParallelism(16);
orderStream.keyBy(Order::getChannel)
.window(…)
.aggregate(…)
.setParallelism(16); // 关键算子单独设置
异步IO写入Redis(而不是同步写入):

java
// 异步写入Redis,提高吞吐量
DataStream resultStream = …
resultStream.mapAsync(100, new AsyncFunction<AggResult, Void>() {
@Override
public void asyncInvoke(AggResult input, ResultFuture resultFuture) {
redisClient.asyncSetex(key, 60, value)
.thenAccept(v -> resultFuture.complete(Collections.singletonList(null)))
.exceptionally(e -> {
resultFuture.completeExceptionally(e);
return null;
});
}
});
Kafka增加分区:从8个分区增加到32个,提高并行消费能力。

坑2:状态爆炸(State太大)
现象:使用滑动窗口(60秒窗口,每秒滑动一次),状态大小达到50GB,Checkpoint每次耗时5分钟。

原因:滑动窗口会产生60个重叠窗口,每个窗口都保存了完整的聚合状态。

解决方案:使用增量聚合(AggregateFunction) 而非全量聚合(ReduceFunction),避免存储所有原始数据。

java
// ✅ 正确:使用AggregateFunction,只存储聚合结果,不存原始数据
.window(SlidingProcessingTimeWindows.of(Time.seconds(60), Time.seconds(1)))
.aggregate(new IncrementalAggregateFunction()) // 增量聚合,内存占用小

// ❌ 错误:使用ReduceFunction或ProcessFunction,可能存储大量原始数据
.window(…)
.reduce((a, b) -> a + b) // 虽然也是增量,但部分场景会缓存数据
坑3:Kafka消息乱序
场景:由于网络延迟,订单A(发生时间10:00:01)比订单B(发生时间10:00:00)先到达Flink,导致窗口计算出现偏差。

解决方案:事件时间(Event Time)+ Watermark

java
// 使用事件时间而非处理时间
orderStream.assignTimestampsAndWatermarks(
WatermarkStrategy.forBoundedOutOfOrderness(Duration.ofSeconds(5)) // 允许5秒乱序
.withTimestampAssigner((order, timestamp) -> order.getCreateTime().getTime())
);

// 窗口使用事件时间
.window(TumblingEventTimeWindows.of(Time.seconds(5)))
九、 最终成果与大屏效果
经过两轮架构升级,最终成果如下:

指标 优化前(定时任务) 优化后(Flink实时计算) 优化幅度
数据延迟 30秒~10分钟 <500ms 提升 >99%
大屏刷新频率 每30秒一次 每秒推送 提升 30倍
数据处理吞吐量 约5000条/秒 150万条/秒 提升 300倍
大屏数据准确率 约95%(有统计延迟) 99.99% 提升显著
大屏同时在线数 最多5个(多开会卡) 50+个无压力 提升 10倍
大促期间最大QPS 无法支撑 80万QPS(消息处理) 从不可用到稳定
大屏最终展示内容:

核心数据区:实时成交额、订单量、支付人数、客单价(每秒更新)

趋势图区:近1小时成交额趋势(每秒更新)

热销榜区:实时热销商品Top10(每10秒更新)

渠道分布区:APP/H5/小程序的成交占比(每5秒更新)

流量监控区:实时PV、UV、在线用户数(每秒更新)

十、 总结与未来规划
10.1 核心经验总结
经验 具体内容
选对引擎 实时大屏必须用真正的流处理引擎(Flink),定时任务方案天花板太低
分层设计 数据采集层(Kafka)→ 计算层(Flink)→ 存储层(Redis)→ 展示层(WebSocket),各层解耦
准确性优先 Exactly-Once + 端到端对账,宁可慢一点也不能错
可观测性 Flink任务监控(Checkpoint成功率、反压、延迟)、数据质量监控、业务指标监控
容错设计 Checkpoint + 自动重启 + 数据回溯能力,确保故障后快速恢复
10.2 未来规划
Flink SQL化:将部分复杂计算迁移到Flink SQL,降低开发门槛,让数据开发同学也能参与。

实时数仓:构建"实时数仓"(Kafka + Flink + ClickHouse),支持运营自助实时查询。

AI智能预警:基于实时数据流,结合时序异常检测算法(如Prophet、Isolation Forest),自动发现异常波动并告警。

大屏3D化:前端大屏升级到3D可视化(Three.js),提升视觉冲击力。

文末互动:
你们的实时大屏是怎么实现的?用的Flink还是Spark Streaming?大促时有没有因为数据不准被老板骂过?欢迎在评论区分享你们的血泪史!

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐