自助查询平台的稳定性治理,主要解决的是用户重复点查询、后台重复跑 SQL 的问题。
当时设计了一个基于「幂等 Key + 查询指纹」的方案:
- 前端用 Idempotency-Key 表达一次查询意图,服务端通过唯一索引保证一个任务只会被提交一次;
- 同时服务端算查询指纹,做结果复用,用来兜住没有 Key 的请求。
上线后重复查询拦截了三成左右,大查询的并发和资源浪费明显下降,用户那边最直观的反馈是:点多少次,只生成一个导出文件。
一、场景描述
做自助查询平台,最容易被低估的问题,其实不是 SQL 写得好不好,而是"用户点了一下,系统算了几遍"。
在 OLAP 场景里,一次查询就是一次昂贵的资源消费。一条 SQL 在 ClickHouse 或 Spark 上扫几亿行,跑上几十秒,是很日常的事。但用户感知不到这些------他们只看到页面卡了,于是又点一下,再点一下。结果就是后台并发起了三四个一模一样的任务,计算资源被白白吃掉,导出目录里躺着三份一样的文件,查询历史里堆满重复记录。
我们后来给整个自助查询体系做幂等,出发点很简单:不能让用户的一个动作,变成系统的 N 次计算。
很多人一听"幂等",第一反应是 HTTP 语义、POST 去重、Redis 的 SETNX。但对自助查询来说,这些都不够。HTTP 幂等解决的是接口调用,而我们真正要解决的是:任务只执行一次,结果可以复用,用户不会为一次点击付出多次代价。
所以整个方案围绕一句话展开:用"查询唯一标识"做执行权仲裁,用"查询指纹"做结果复用,前者是强约束,后者是优化。
二、整体方案
1、Idempotency-Key 与 数据库唯一键
第一步,我们先把"谁说了算"这件事定下来。
最早我们试过用 requestId 或 traceId 去防重,很快发现行不通------重试时 requestId 一定会变,它描述的是一次网络传输,不是用户的一次查询意图。于是我们反过来要求前端:你来告诉系统,这是不是"同一次操作"。
前端在发起查询时,生成一个 Idempotency-Key,规则很直白:业务场景 + 用户 ID + 查询参数的 Hash。比如一次导出,参数没变,Key 就不变;用户改了筛选条件,Hash 变了,Key 也变。这个 Key 从浏览器带到服务端,代表"用户想干这件事"。
服务端拿到 Key 后,第一件事不是拼 SQL,而是去幂等表里插一条记录。表结构很小,核心是 idempotency_key 唯一索引 ,再加一个状态机:PREPARE → RUNNING → SUCCESS/FAILED。
- 插入成功,说明你是第一个来干这件事的,状态推进到 RUNNING,再去真正提交查询任务;
- 插入失败,说明这件事已经有人接了,系统不会再起新任务,而是根据状态把已有任务 ID 或结果地址返回。
这里本质上已经不是"接口防抖",而是把查询接口变成了一个任务系统。无论用户点多少次,真正能拿到 RUNNING 权限的,只有一个事务。
2、 查询指纹
但问题没有结束。现实里总有不传 Key 的情况:老页面、第三方系统、刷新页面后前端状态丢了。如果只靠 Key,这些请求就会绕过幂等。于是我们补了一层服务端的能力------查询指纹。
一、指纹计算:指纹是对"查询语义"的摘要。我们把前端传来的 DSL 或者后端拼出来的 SQL,先做规范化:
- 去掉空格换行
- 把过滤条件按字段排序、IN 里的数组也排序
- 时间范围、维度、指标、数据源
这些信息拼在一起,算一个 sha256。这个 hash,就是对"这次查的是什么"的抽象。
二、捞指纹候选
指纹在数据库里只是个普通索引,不是唯一约束。
当一个请求没有 Idempotency-Key 时,系统会算指纹,找最近一段时间内、同用户、同数据源、指纹相同的成功任务,然后在内存里把原始查询参数逐条比对,只有完全对上才复用结果。所以指纹只是帮我们少扫表,不做决策。
只有全部对上,才复用结果。指纹只是把范围缩小到"可能一样",最后拍板的,是结构化参数的精确比对。
3、指纹碰撞
SHA-256 理论上当然会碰撞,但工程上我们不假装没有,而是让碰撞无害 。指纹永远和 user_id、数据源绑在一起,不同业务场景策略也不同:
- 导出类接口完全不走指纹复用,只认 Idempotency-Key;
- 即席查询和看板允许复用,但结果只保留几分钟;
- 实时场景我们甚至直接关掉复用。
就算真的发生碰撞,最坏的结果也不是"查错数"(因为参数指纹的对应的参数对不上),而是"没复用上,多算一次"。因为在 OLAP 系统里,我们更怕的是把 A 条件的结果给到 B,而不是多扫一次表。
4、任务结果复用
异步查询让这件事变得更自然。自助查询基本都是提交任务、轮询状态、最后拿结果。
幂等 Key 绑定的是 task,而不是某一次 HTTP 请求。用户第一次提交,拿到一个 taskId;再点,还是这个 taskId;轮询接口永远只问这一个任务。哪怕任务跑完了,第二天同一个 Key 再来,也能拿到结果。
用户感知到的,就是一个稳定的查询进度,而不是"怎么又起了一个新任务"。
三、小结
上线之后,最直观的变化其实不在监控图上,而在用户侧。
- 以前导出任务经常有人问"为什么我邮箱里三份文件",后来基本没有了;
- 查询历史里那种一眼看过去十行一样的记录,也消失了。
- 后台看到的,是重复请求拦截率稳定在 30% 左右,大查询的并发度明显收敛,Spark 和 ClickHouse 的"无用功"少了很多。
回过头看,这套东西并没有什么黑科技。它只是把一件很朴素的事做完整了:
- 让客户端声明"我想干什么";
- 让服务端保证"这件事只干一次";
- 让系统有能力认出"这件事是不是已经有人干过了"。
我们常说缓存能解决慢,但解决不了乱;限流能解决炸,但解决不了浪费。在自助查询这种"读很贵"的系统里,幂等不是锦上添花,而是资源管理的底线。
如果你也在做 BI、数据提取、或者任何面向用户跑 SQL 的系统,我的建议很简单:先别急着优化执行计划,先保证同一条 SQL,不会在同一个用户的一次操作里,被跑两遍。剩下的,都是在这个前提下做加法。