实时大屏项目复盘:从需求沟通到性能优化的全流程记录

实时大屏项目复盘:从需求沟通到性能优化的全流程记录

老板说"下周高管会要展示一块实时数据大屏",于是数据分析师就开启了为期两周的"全栈"之旅——从需求沟通、数据对接、接口开发到性能调优,每个环节都有意想不到的坑。今天做一次完整复盘。

一、需求沟通:你以为的"大屏"可能不是同一个东西

项目启动会上,业务方给我发了一张参考图:"就类似这种,数据要实时刷新。"我一看,好家伙——地图热力、跑马灯、动态排名、环形图、趋势折线……粗略一数至少 12 个图表模块。

这时候不能直接说"行,我开干了"。需求沟通的第一要务是:对齐"实时"的定义。

最终对齐的结果是:核心指标(GMV、订单量、UV)需要5 秒级刷新,图表数据30 秒级刷新,地图热力数据1 分钟刷新即可。这直接决定了技术方案——不同刷新频率的数据走不同通道。

还有一个被忽略但致命的细节:大屏要展示的是"当日累计"还是"实时瞬时"?业务方原话是"展示实时数据",但实际上他们想要的是"今日截至当前的累计值",而不是"这一刻正在发生的值"。一字之差,数据逻辑天差地别。

二、数据层:ClickHouse 物化视图加速

大屏的查询场景非常固定——都是按分钟/小时粒度的聚合查询。如果用 MySQL 直接查明细表,大屏刷新一次可能要跑十几秒。ClickHouse 在这种场景下是更合适的选择。

-- ClickHouse 物化视图:每分钟自动聚合订单数据 CREATE MATERIALIZED VIEW mv_order_min_agg ENGINE = AggregatingMergeTree() PARTITION BY toYYYYMMDD(minute_time) ORDER BY (minute_time, category_id) POPULATE -- 自动回填历史数据 AS SELECT toStartOfMinute(order_time) AS minute_time, category_id, -- 订单量 countState() AS order_count_state, -- GMV 求和 sumState(order_amount) AS gmv_state, -- 用户数去重 uniqState(user_id) AS uv_state FROM orders GROUP BY minute_time, category_id; -- 查询时使用 Merge 后缀函数合并聚合状态 SELECT minute_time, category_id, countMerge(order_count_state) AS order_count, sumMerge(gmv_state) AS gmv, uniqMerge(uv_state) AS uv FROM mv_order_min_agg WHERE minute_time >= today() GROUP BY minute_time, category_id ORDER BY minute_time DESC;

物化视图的好处是:数据写入时自动完成聚合,查询时直接读结果,不需要每次刷新大屏都扫明细表。实测下来,12 个图表模块的所有查询从原来的 8-15 秒压缩到了 300ms 以内。

三、接口层:并发请求 + 分级缓存

大屏前端的 12 个图表模块如果串行请求,总耗时是sum(每个接口耗时),用户体验极差。前后端分离后,前端可以并发请求,但后端要顶住 12 个查询同时打过来的压力。

import asyncio import aiomysql from functools import lru_cache import time class DashboardService: """大屏数据服务""" def __init__(self): self.cache = {} # 简单内存缓存 async def fetch_all_dashboard_data(self): """并发获取所有大屏模块数据""" tasks = [ self.get_gmv_trend(), # GMV 趋势 self.get_order_ranking(), # 品类排行 self.get_uv_realtime(), # 实时 UV self.get_map_heatmap(), # 地图热力 self.get_conversion_funnel(),# 转化漏斗 self.get_top_products(), # 热销商品 ] # asyncio.gather 并发执行所有查询 results = await asyncio.gather(*tasks, return_exceptions=True) # 组装返回,对异常的模块返回占位数据 module_names = ['gmv_trend', 'order_ranking', 'uv', 'heatmap', 'funnel', 'top_products'] response = {} for name, result in zip(module_names, results): if isinstance(result, Exception): response[name] = {'error': str(result), 'data': None} else: response[name] = result return response def get_gmv_trend(self): """GMV 趋势 —— 30 秒缓存""" cache_key = 'gmv_trend' if cache_key in self.cache: cached_time, cached_data = self.cache[cache_key] if time.time() - cached_time < 30: # 缓存 30 秒 return cached_data # 执行查询并更新缓存 data = self._query_gmv_from_clickhouse() self.cache[cache_key] = (time.time(), data) return data

这里踩的最大的坑是:Python 的asyncio和 ClickHouse 驱动的兼容性。ClickHouse 的 Python 客户端clickhouse-driver默认是同步的,需要在线程池中执行。我们后来换成了asynch(ClickHouse 官方异步客户端),性能提升了约 40%。

四、性能优化与上线

压测阶段发现两个瓶颈:

瓶颈一:数据库连接池耗尽。12 个并发查询 + 5 秒刷新频率,加上同时可能有多个大屏页面打开(投屏、PC、平板),连接数瞬间打满。解决方案是将连接池从默认的 5 提升到 50,并增加连接超时回收机制。建议同时给连接池加上慢查询日志,便于上线后快速定位是哪个查询吃掉了连接。

瓶颈二:前端渲染性能。地图热力图在数据量超过 5000 个点后出现明显卡顿。解决思路是在服务端做数据抽稀——只返回前 2000 个热点:

def downsample_heatmap(points, max_points=2000): """地图热力点抽稀,保证前端渲染流畅""" if len(points) <= max_points: return points # 按热度值降序排列,保留前 max_points 个 points_sorted = sorted(points, key=lambda x: x['value'], reverse=True) return points_sorted[:max_points]

上线当天的监控有必要设好——特别是数据库慢查询、接口响应时间和错误率:

import logging import time from functools import wraps def monitor_api(api_name): """API 性能监控装饰器""" def decorator(func): @wraps(func) async def wrapper(*args, **kwargs): start = time.perf_counter() try: result = await func(*args, **kwargs) elapsed = time.perf_counter() - start # 记录接口耗时,超过 1 秒告警 if elapsed > 1.0: logging.warning( f"[{api_name}] 响应超时: {elapsed:.2f}s" ) return result except Exception as e: elapsed = time.perf_counter() - start logging.error( f"[{api_name}] 异常: {e}, 耗时: {elapsed:.2f}s" ) raise return wrapper return decorator

五、总结

大屏项目看起来是"做一个展示页面",但实际上考验的是数据流全链路的工程能力。从需求沟通阶段对"实时"定义的澄清,到数据层物化视图的预聚合,再到接口层的并发优化和分级缓存,以及上线后的性能监控——缺少任何一个环节都会翻车。

最重要的教训只有一条:**大屏的性能瓶颈永远在数据层和查询层,而不是前端渲染层。**用对 ClickHouse 的物化视图,比在前端做任何优化都管用十倍。

最后提醒一点:这个方案在上生产之前建议先用灰度流量验证一周,确认资源消耗在预期范围内再全量推送。我们在实际项目中因为跳过了这步,有一次把缓存集群打挂了,教训深刻。