移动端ClickHouse排障:HTTP接口与SwiftUI实战
每次大半夜被告警电话叫醒第一反应都是身边没有电脑怎么能尽快看一眼 ClickHouse 集群到底出了什么问题服务端的排障工具链已经很成熟了但移动端能快速连上 ClickHouse、执行一条查询、看一眼集群状态和慢查询的工具一直不多。最近在 Hacker News 上关注到一个很有意思的项目 ProbeDeck定位非常精准——一个面向 ClickHouse incident triage事件分诊的 iOS 应用。了解了一圈之后发现这类工具的底层并不神秘核心就是一个“ClickHouse HTTP 客户端 排障模板库”。所以本文不打算只做应用介绍而是把移动端做 ClickHouse 排障的完整技术链路拆开讲清楚ClickHouse 的 HTTP 查询接口、认证方式、系统表查询、慢查询与集群状态分析以及如何用 SwiftUI 从零实现一个极简版排查工具。无论你是 ClickHouse 运维还是 iOS 开发者都可以把文中代码和 SQL 直接拿去参考。围绕这个目标我会按以下顺序展开先解释 incident triage 的概念和 ClickHouse 排障的典型场景然后说明 ClickHouse HTTP 接口与认证选型接着给出排障必须掌握的系统表和 SQL再动手写一个可以在 Xcode 中运行的 SwiftUI 客户端最后整理高频事故的处理思路和工程落地建议。1. 背景为什么需要移动端的 ClickHouse 排障工具1.1 什么是 Incident TriageIncident Triage 直译过来是“事件分诊”在很多团队里也叫“故障初判”“值班定位”。它指的是系统出现异常之后值班人员所做的第一轮定位动作。需要注意Triage 不等于完整的根因分析它的目标是在最短时间内回答几个关键问题现象是什么比如接口超时、数据延迟、磁盘告警影响面有多大比如是全部节点异常还是单个副本离线当前最可能的瓶颈在哪里比如写入变慢、查询卡顿、资源耗尽还是认证配置出错。只有先把这些问题快速锚定后面的根因分析和修复才有方向。在日常的 ClickHouse 运维中这个阶段通常非常考验工具能力。电脑端我们可以用 Tabix、DataGrip、DBeaver或者直接使用 clickhouse-client 执行命令但在真实值班场景里很多告警发生在深夜或非办公环境手边可能只有一部手机。此时如果有一个移动端工具能快速连上集群、执行几条排障 SQL定位效率会完全不同。1.2 ClickHouse 排障的常见场景ClickHouse 是列式 OLAP 数据库广泛用于日志分析、用户行为分析、监控指标存储等场景。因为写入吞吐高、查询速度快的特性很多公司的核心数据链路都依赖它。线上一旦出问题排障通常围绕以下几个维度展开排障方向常见问题关键对象连接与认证客户端连接报错、认证失败、端口不通users.xml、密码策略、监听端口集群状态副本离线、分片不可用、Keeper 异常system.clusters、system.replicas查询性能查询变慢、内存占用过高、CPU 飙高system.processes、system.query_log数据写入写入延迟、part 过多、merge 跟不上system.parts、system.mutations存储水位磁盘不足、备份失败、detached 文件堆积system.disks、system.detached_parts不同的场景需要看的系统表和 SQL 不一样但整体套路是一致的先看拓扑再看进程然后查日志最后定位资源和数据分布。下面会逐一给出对应的查询语句。1.3 为什么移动端也要做 ClickHouse 排障选择在 iOS 端做 ClickHouse 排查并不是要把所有运维工作都塞进手机而是聚焦“告警触发后最快看到状态”这一个环节。移动端有一个 Web 页面无法替代的优势系统级推送。当监控系统发现 ClickHouse 集群异常时可以直接推送一条消息到值班人员手机点击推送后立刻进入排障页面不再需要打开电脑、连接内网、找到运维工具、输入账号密码这一长串操作。另一个优势是沉淀模板。一个团队最常用的排障 SQL 其实就那么十几条把它做成模板放在 App 里值班人员即使是新人也能照着点一遍快速收集诊断信息。ProbeDeck 这类产品本质上就是在做这件事。1.4 ProbeDeck 这类工具的本质如果拆解一下ProbeDeck 可以理解为“移动端 ClickHouse HTTP 客户端 排障 SQL 模板库”的组合。它并没有改变 ClickHouse 本身而是把最常用、最关键的排障动作产品化。理解了这一点你就会发现即使不用现成的 App自己也能实现一个核心版本一个连接配置页、一个 SQL 输入框、一个结果展示区再加上几个常用模板。这正是本文第 4 章要做的实战内容。2. 环境准备与接口选型2.1 HTTP 接口还是原生 TCP 协议ClickHouse 对外提供多种访问接口了解它们之前的差异有助于在移动端场景中做选型。最常用的是两类一是原生 TCP 协议默认端口 9000clickhouse-client 命令行工具默认走这个协议二是 HTTP 接口默认端口 8123通过 HTTP POST/GET 发送 SQL并返回文本格式的结果。除此之外ClickHouse 还支持 MySQL 协议和 PostgreSQL 协议的兼容端口方便其他生态工具接入。移动端开发时我强烈推荐使用 HTTP 接口原因很实际HTTP 接口跨平台、跨语言不依赖特定客户端 SDKiOS 原生 URLSession 就能访问返回格式可以通过参数灵活控制比如 CSV、JSON、JSONEachRow非常方便在手机上解析展示同时 HTTP 请求便于抓包调试排查问题更直观。原生 TCP 协议虽然性能更好但需要实现完整协议栈对移动端来说完全没有必要。2.2 认证方式ClickHouse HTTP 接口支持多种认证方式每次只选择其中一种即可。最简单的做法是直接在 URL 上带参数例如?userdefaultpasswordxxx。不过这种写法会把密码暴露在 URL 中容易留在代理日志或访问日志里不建议在真实环境使用。更推荐的方式是使用 HTTP Header通过X-ClickHouse-User和X-ClickHouse-Key分别传递用户名和密码密码不出现在 URL 中。如果 ClickHouse 服务端开启了 Basic Auth也可以使用Authorization: Basic ...标准方式。这里有一个非常高频的坑ClickHouse 的默认用户default在本地测试时可能不需要密码但生产环境通常会配置密码。一旦密码不匹配服务端会返回一段包含DB::Exception的报错信息错误码通常是 193文字提示类似default: Authentication failed: password is incorrect, or there is no user with such name。这个问题后面会在第 6 节专门展开。2.3 返回格式选择HTTP 接口默认返回 TabSeparated 纯文本字段之间用制表符分隔。这种格式简单但解析起来不够直观。移动端推荐显式指定formatJSONEachRow也就是每行返回一个 JSON 对象形如{version:24.8.4.13}。这种方式的好处是每一行都可以独立解析非常适合流式处理同时字段类型基本保留原始语义前端展示非常方便。如果一次查询的数据量不大也可以使用formatJSON一次返回完整 JSON但需要注意整体响应体可能比较大。2.4 开发环境本文的示例代码基于以下环境读者可根据自己本机情况调整版本Xcode 15 及以上版本Swift 5.9 及以上版本使用 Swift Concurrency 的 async/awaitSwiftUI部署目标建议 iOS 16 及以上一个可访问的 ClickHouse 实例本地 Docker 或测试集群均可不需要引入任何第三方依赖全部使用系统框架。3. 核心知识ClickHouse 排障必须掌握的系统表3.1 system.clusters查看集群拓扑当用户反馈“某个查询报错”“某个分片数据不对”时第一步通常是确认集群的拓扑信息。ClickHouse 的集群配置写在config.xml或config.d目录下的 XML 文件中system.clusters表会把这些配置实时展示出来。常用查询如下SELECT cluster, host_name, host_address, port, is_local, shard_num, replica_num FROM system.clusters WHERE cluster default ORDER BY shard_num, replica_num查询结果能告诉我们几个关键信息集群中包含哪些节点每个分片有几个副本当前节点在集群中的位置。如果这条查询返回空结果很可能说明当前节点并不属于你指定的集群或者集群配置还没有正确加载。排障时看到副本数量少于预期就应该立刻去检查对应节点是否存活、ClickHouse 进程是否还在运行。3.2 system.processes正在执行的查询当集群出现“查询卡住”“CPU 飙高”时首先要看的就是当前正在执行的查询。system.processes等同于 MySQL 的SHOW PROCESSLIST它记录了正在运行的查询和资源使用情况。常用 SQLSELECT query_id, user, elapsed, read_rows, read_bytes, memory_usage, query FROM system.processes ORDER BY elapsed DESC LIMIT 20这里每一列都有明确的排障意义query_id是查询的唯一标识后续 KILL 查询时要用到elapsed是已经执行的时间单位秒read_rows和read_bytes反映查询扫描的数据量值越大越可疑memory_usage表示查询占用的内存。如果看到一个查询的elapsed很长、read_bytes又非常大它很可能就是拖慢集群的元凶。确认后可以记录query_id再使用KILL QUERY WHERE query_id ...终止该查询。3.3 system.query_log历史慢查询与错误查询排障不能只看当前时刻很多时候需要回溯“刚才发生了什么”。system.query_log是 ClickHouse 的查询日志表记录每一条查询的开始时间、结束时间、执行时长、扫描数据量、用户、异常等信息。常用慢查询 SQL 如下SELECT query_start_time, query_duration_ms / 1000 AS duration_sec, user, read_rows, read_bytes, query FROM system.query_log WHERE type QueryFinish ORDER BY query_duration_ms DESC LIMIT 20如果同一条 SQL 反复出现在慢查询列表最前面说明这不是偶发问题而是业务侧需要优化的固定瓶颈。对于失败查询可以过滤type ExceptionWhileProcessing查看exception_code和exception字段能快速定位到具体是什么类型的错误。有一点需要注意query_log是异步写入的默认可能会有几秒到几十秒的延迟排障时要留出这个时间差。3.4 system.parts 与 system.disks数据分布与存储ClickHouse 表在物理存储上会切分成多个 partsystem.parts表记录了每个 part 的信息。常用存储巡检 SQLSELECT database, table, formatReadableSize(sum(bytes_on_disk)) AS disk_size, count() AS part_count FROM system.parts WHERE active 1 GROUP BY database, table ORDER BY sum(bytes_on_disk) DESC LIMIT 20active 1表示只统计当前生效的数据部分避免把后台合并中的临时数据也算进去。formatReadableSize函数可以把字节数格式化成人类可读的大小比如 “1.23 GiB”。如果某个表的part_count非常大同时disk_size增长很快可能说明写入频率过高而后台 merge 跟不上需要检查分区键设计是否合理或者是否该增加optimize策略。4. 完整实战用 SwiftUI 搭建极简 ClickHouse 排障工具下面用一个最小可运行示例演示如何实现上一章提到的核心查询。不需要复杂架构只使用系统框架却已经具备了 ProbeDeck 这类工具的核心原型连接配置、SQL 模板、查询执行、结果展示。4.1 创建项目与目录结构在 Xcode 中创建一个新的 SwiftUI 项目名字可以叫ProbeDeckDemo。示例项目结构如下ProbeDeckDemo/ ├── ProbeDeckDemoApp.swift ├── ContentView.swift ├── Models/ │ └── ConnectionConfig.swift └── Services/ └── ClickHouseClient.swift依赖方面本项目只使用系统框架不引入第三方库。这样读者复制代码后可以直接编译运行避免版本冲突。4.2 连接配置模型文件路径Models/ConnectionConfig.swiftimport Foundation struct ConnectionConfig: Codable { var name: String 本地测试 var host: String http://127.0.0.1:8123 var user: String default var password: String }这段代码定义了一个连接配置模型。name字段用于标识不同的集群环境比如“生产集群”“测试集群”方便切换host是 ClickHouse HTTP 接口地址user和password对应访问账号。为了演示方便这里先使用明文存储但实际工程中密码一定不能放在 UserDefaults 或普通文本里应该使用 Keychain 保存后面第 7 节会再强调。4.3 HTTP 查询封装文件路径Services/ClickHouseClient.swiftimport Foundation enum ClickHouseError: LocalizedError { case invalidURL case serverError(Int, String) var errorDescription: String? { switch self { case .invalidURL: return 无效的连接地址 case .serverError(let code, let message): return HTTP \(code): \(message) } } } struct ClickHouseClient { let config: ConnectionConfig /// 执行查询返回 JSONEachRow 解析后的结果 func query(_ sql: String) async throws - [[String: Any]] { guard var url URL(string: config.host) else { throw ClickHouseError.invalidURL } var components URLComponents(url: url, resolvingAgainstBaseURL: false) var queryItems components?.queryItems ?? [] queryItems.append(URLQueryItem(name: format, value: JSONEachRow)) components?.queryItems queryItems guard let finalURL components?.url else { throw ClickHouseError.invalidURL } var request URLRequest(url: finalURL) request.httpMethod POST request.httpBody sql.data(using: .utf8) request.setValue(config.user, forHTTPHeaderField: X-ClickHouse-User) request.setValue(config.password, forHTTPHeaderField: X-ClickHouse-Key) request.timeoutInterval 15 let (data, response) try await URLSession.shared.data(for: request) guard let httpResponse response as? HTTPURLResponse else { throw ClickHouseError.serverError(-1, 无响应) } let text String(data: data, encoding: .utf8) ?? guard httpResponse.statusCode 200 else { throw ClickHouseError.serverError(httpResponse.statusCode, text) } return Self.parseJSONEachRow(text) } private static func parseJSONEachRow(_ text: String) - [[String: Any]] { text .split(separator: \n) .compactMap { line in let data Data(String(line).utf8) return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] } } }这里有几个关键点需要解释。请求方法使用 POSTSQL 放在请求体中避免超长 SQL 出现在 URL 里。通过 URL 参数formatJSONEachRow指定返回格式这样服务端不会返回默认的 TabSeparated而是每行一个 JSON。用户名和密码通过X-ClickHouse-User、X-ClickHouse-Key两个 Header 传递比 URL 参数更安全。超时时间设置为 15 秒避免手机端网络弱、后端查询慢时长时间卡住界面。parseJSONEachRow函数按行切分并解析 JSON遇到某一行解析失败会直接跳过不会导致整个响应解析崩溃。4.4 查询页面文件路径Views/QueryView.swiftimport SwiftUI struct QueryView: View { State private var queryText: String State private var outputText 等待查询... State private var isLoading false let client: ClickHouseClient init(client: ClickHouseClient, initialQuery: String) { self.client client _queryText State(initialValue: initialQuery) } var body: some View { VStack(alignment: .leading, spacing: 12) { TextEditor(text: $queryText) .font(.system(.body, design: .monospaced)) .frame(height: 120) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(Color.gray.opacity(0.4)) ) HStack { Button(isLoading ? 执行中... : 执行查询) { Task { await runQuery() } } .disabled(isLoading) Button(清空) { outputText } } ScrollView { Text(outputText) .font(.system(.caption, design: .monospaced)) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) } .frame(maxHeight: .infinity) } .padding() .navigationTitle(查询) } private func runQuery() async { isLoading true defer { isLoading false } do { let rows try await client.query(queryText) outputText formatRows(rows) } catch { outputText 查询失败\(error.localizedDescription) } } private func formatRows(_ rows: [[String: Any]]) - String { guard !rows.isEmpty else { return 查询完成返回 0 行 } return rows.prefix(200).map { row in row.keys.sorted().map { key in \(key): \(row[key] ?? ) }.joined(separator: , ) }.joined(separator: \n) } }这个页面实现了最核心的交互上方是 SQL 输入框下方是执行按钮和结果展示区域。执行按钮点击后通过Task启动异步查询查询期间禁用按钮防止重复提交。结果区域最多展示前 200 行避免一次性渲染大量文本导致滚动卡顿。为了让输出更方便阅读使用等宽字体并开启了系统文本选择能力手机上可以长按复制关键内容。4.5 主页面连接配置与常用模板文件路径ContentView.swiftimport SwiftUI struct TemplateQuery: Identifiable { let id UUID() let title: String let sql: String } let templateQueries [ TemplateQuery( title: 查看集群拓扑, sql: SELECT cluster, host_name, host_address, is_local, shard_num, replica_num FROM system.clusters LIMIT 20 ), TemplateQuery( title: 当前正在执行的查询, sql: SELECT query_id, user, elapsed, read_rows, read_bytes, memory_usage, query FROM system.processes ORDER BY elapsed DESC LIMIT 20 ), TemplateQuery( title: 最近慢查询, sql: SELECT query_start_time, query_duration_ms / 1000 AS duration_sec, user, read_rows, read_bytes, query FROM system.query_log WHERE type QueryFinish ORDER BY query_duration_ms DESC LIMIT 20 ), TemplateQuery( title: 表占用磁盘 TOP20, sql: SELECT database, table, formatReadableSize(sum(bytes_on_disk)) AS disk_size, count() AS part_count FROM system.parts WHERE active 1 GROUP BY database, table ORDER BY sum(bytes_on_disk) DESC LIMIT 20 ) ] struct ContentView: View { State private var config ConnectionConfig() State private var showQuery false State private var pendingSQL SELECT version() var body: some View { NavigationStack { Form { Section(连接配置) { TextField(