一个 Trino 查询突然变慢,Query ID 是 20231027_083000_12345_abcde
。现在,你需要做什么?传统的做法是SSH到 Trino coordinator 和多个 worker 节点上,用 grep
在海量日志文件中搜索这个ID,试图拼凑出执行计划、资源争用、或者潜在的GC停顿。这个过程不仅耗时,而且在动态伸缩的云原生环境中,节点来了又走,日志可能已经丢失。这是一个典型的、高压的生产环境问题,它暴露了可观测性的碎片化。
我们的团队也长期被这个问题困扰。我们拥有强大的 Trino 集群处理海量数据联邦查询,使用 Loki 收集所有容器日志,并通过 GitOps (ArgoCD) 管理着整个基础设施的声明式状态。每个工具都运行良好,但它们是孤立的。排查问题时,工程师需要在多个系统之间手动关联信息,效率极低。
痛点催生了构想:为什么不能用 Trino 自己来解决 Trino 的问题?Trino 的核心能力是联邦查询——跨越不同数据源进行统一查询。如果我们将 Loki 的日志流和记录着关键上下文的元数据存储(例如一个 NoSQL 键值数据库)都作为 Trino 的数据源,我们就可以用一条 SQL 来完成过去需要数小时手动操作才能完成的复杂诊断。
这个方案的核心在于构建一个闭环的、自动化的诊断平台。所有组件,包括 Trino 本身、它的数据源连接器、以及日志收集代理,都通过 GitOps 进行管理。这不仅保证了环境的一致性,也使得整个诊断体系的演进变得可追溯、可审计。
技术选型与架构决策
- Trino 作为查询引擎: 这是不二之选。我们用它来查询它自己的运行状态,利用其强大的连接器生态。
- Loki 作为日志数据源: Loki 的优势在于其基于标签的索引模型,对于云原生环境非常友好且成本可控。我们将通过
trino-loki
连接器将其接入 Trino。 - Redis 作为 NoSQL 元数据存储: 我们需要一个地方存储查询ID与“外部世界”的关联信息,比如触发这个查询的业务批次ID、Git commit hash、或者执行该查询的用户的团队信息。Redis 速度快、部署简单,作为键值存储非常适合这种高频写入、快速读取的场景。我们将使用
trino-redis
连接器。 - ArgoCD 与 Kustomize 作为 GitOps 实现: 我们使用 ArgoCD 来同步 Git 仓库中的声明式配置到 Kubernetes 集群。Kustomize 则用于管理不同环境(开发、预发、生产)的配置差异,尤其是 Trino 连接器的配置。
整体架构如下:
graph TD subgraph Git Repository direction LR A[ArgoCD App Manifests] B[Kustomize Overlays] C[Trino Connector Configs] D[Loki Configs] end subgraph Kubernetes Cluster direction TB F[ArgoCD Controller] --Syncs--> G subgraph Trino Ecosystem G[Trino Coordinator] H[Trino Workers] I[Loki Connector ConfigMap] J[Redis Connector ConfigMap] end subgraph Observability Stack K[Loki] L[Redis] M[Log Forwarder e.g., Promtail] end G --Federated Query--> K G --Federated Query--> L H --Writes Logs--> M --Sends Logs--> K end E[Developer/SRE] --kubectl apply or git push--> A E --Writes SQL--> G subgraph CI/CD Pipeline P[Event Listener] --Query Event--> Q[Metadata Injection] end G --Emits Event--> P Q --Writes Metadata--> L linkStyle 0 stroke:#ff9900,stroke-width:2px; linkStyle 1 stroke:#ff9900,stroke-width:2px; linkStyle 2 stroke:#ff9900,stroke-width:2px; linkStyle 3 stroke:#ff9900,stroke-width:2px;
这里的关键点在于,不仅应用本身,连 Trino 的连接器配置 (I)
和 (J)
也是通过 GitOps 管理的 ConfigMap
。当我们需要添加一个新的数据源或调整现有连接器的参数时,我们修改的不再是服务器上的文件,而是 Git 仓库中的 YAML。
GitOps 驱动的配置管理实践
我们的 Git 仓库结构如下,清晰地分离了基础配置和环境特定配置:
trino-platform/
├── base/
│ ├── trino/
│ │ ├── coordinator-deployment.yaml
│ │ ├── worker-statefulset.yaml
│ │ ├── service.yaml
│ │ ├── configmap-trino-properties.yaml
│ │ └── configmap-connectors.yaml # 基础连接器配置占位
│ ├── loki/
│ │ └── ...
│ └── redis/
│ └── ...
│ └── kustomization.yaml
└── overlays/
├── production/
│ ├── patch-coordinator-resources.yaml
│ ├── patch-worker-replicas.yaml
│ ├── configmap-loki-connector.yaml
│ ├── configmap-redis-connector.yaml
│ └── kustomization.yaml
└── staging/
└── ...
在 base/trino/configmap-connectors.yaml
中,我们可能只有一个空的占位符或通用配置。真正的配置在 overlays/production
中定义。
overlays/production/configmap-loki-connector.yaml
文件内容如下。这是声明式配置的核心。
# overlays/production/configmap-loki-connector.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: trino-connector-loki
namespace: trino
data:
# 此文件名 `loki.properties` 将作为 catalog 名称
loki.properties: |
connector.name=loki
loki.uri=http://loki.monitoring.svc.cluster.local:3100
# 在生产中,我们关心最近14天的数据
loki.max-age=14d
# 增加每次请求的行数,以提高大日志扫描的性能
loki.page-size=5000
# 生产环境API请求超时
loki.client.read-timeout=5m
overlays/production/configmap-redis-connector.yaml
文件内容:
# overlays/production/configmap-redis-connector.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: trino-connector-redis
namespace: trino
data:
# Catalog 名称为 `redis_meta`
redis_meta.properties: |
connector.name=redis
redis.nodes=redis-master.data.svc.cluster.local:6379
# 我们将元数据存储在DB 1,与缓存数据隔离
redis.database-index=1
# 我们的元数据 Key 格式是 `trino:query:meta:{queryId}`
redis.key-prefix=trino:query:meta:
# 假设所有元数据都是 JSON 字符串
redis.default-schema=default
# 关键性能参数:避免扫描大Key空间
redis.hide-internal-columns=true
redis.key-delimiter=:
然后,overlays/production/kustomization.yaml
文件将这些配置组合起来:
# overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: trino
resources:
- ../../base
# 使用 strategic merge patch 来添加或覆盖配置
configMapGenerator:
- name: trino-connector-loki
behavior: merge
files:
- loki.properties=./configmap-loki-connector.yaml
- name: trino-connector-redis
behavior: merge
files:
- redis_meta.properties=./configmap-redis-connector.yaml
patches:
- path: patch-coordinator-resources.yaml
target:
kind: Deployment
name: trino-coordinator
- path: patch-worker-replicas.yaml
target:
kind: StatefulSet
name: trino-worker
Trino Coordinator 的 Deployment 需要挂载这些 ConfigMap
到 /etc/trino/catalog
目录下。当我们将这些变更推送到 Git 主分支,ArgoCD 会自动检测到差异并应用到集群中,触发 Trino Coordinator 的滚动更新,新的 catalog 随即生效,整个过程无人值守。
关键环节:元数据注入
有了连接器,数据从哪里来?Loki 的日志由 Promtail 自动收集,但 Redis 中的元数据需要我们主动写入。一个常见的错误是让业务应用代码来做这件事,这会造成业务逻辑和运维逻辑的耦合。
我们选择的方案是利用 Trino 的 EventListener
机制。我们可以开发一个轻量级的插件,监听 Trino 的查询创建和完成事件 (queryCreated
/ queryCompleted
)。
下面是一个简化的 EventListener 实现思路(伪代码),它会在查询创建时捕获元数据并写入 Redis。
// Simplified Java code for a Trino EventListener
import io.trino.spi.eventlistener.EventListener;
import io.trino.spi.eventlistener.QueryCreatedEvent;
import io.trino.spi.eventlistener.QueryCompletedEvent;
import redis.clients.jedis.Jedis;
import com.google.gson.Gson;
import java.util.Map;
public class RedisMetadataEventListener implements EventListener {
private final Jedis jedis;
private final Gson gson = new Gson();
public RedisMetadataEventListener(Map<String, String> config) {
String redisHost = config.getOrDefault("redis.host", "localhost");
int redisPort = Integer.parseInt(config.getOrDefault("redis.port", "6379"));
this.jedis = new Jedis(redisHost, redisPort);
// Add authentication and other configs as needed
}
@Override
public void queryCreated(QueryCreatedEvent event) {
try {
String queryId = event.getMetadata().getQueryId();
String user = event.getContext().getUser();
// X-Trino-Source 头部通常由客户端(如 DBeaver, Superset)设置
String source = event.getContext().getSource().orElse("unknown");
// 通过 session 属性传递业务上下文
String businessBatchId = event.getContext().getSessionProperties().get("business_batch_id");
Map<String, Object> metadata = Map.of(
"queryId", queryId,
"user", user,
"source", source,
"businessBatchId", businessBatchId,
"createQueryTime", event.getCreateTime().toString(),
"query", event.getMetadata().getQuery()
);
String redisKey = "trino:query:meta:" + queryId;
String jsonPayload = gson.toJson(metadata);
// 设置一个合理的过期时间,比如7天,防止Redis被撑爆
// 在生产中,这里应该有重试和错误处理逻辑
jedis.setex(redisKey, 60 * 60 * 24 * 7, jsonPayload);
} catch (Exception e) {
// 在真实项目中,这里需要接入监控和告警,而不仅仅是打印日志
System.err.println("Failed to write query metadata to Redis: " + e.getMessage());
}
}
@Override
public void queryCompleted(QueryCompletedEvent event) {
// 同样可以在这里更新查询的结束时间、状态等信息
}
}
这个插件打包成 JAR 后,通过 GitOps 管理的 ConfigMap
或 initContainer
分发到 Trino 插件目录,并通过 Trino 的配置文件启用。这样,我们就建立了一个自动化管道,将每次查询的上下文信息持久化到了 Redis 中。
联邦化性能诊断实战
回到最初的问题,排查 Query ID 20231027_083000_12345_abcde
。现在,我们不再需要 SSH。我们打开一个 SQL 客户端,连接到同一个 Trino 集群,执行以下查询:
-- This is a production-grade federated query for performance diagnostics.
-- It joins logs from Loki with query metadata from Redis to provide a holistic view.
WITH query_meta AS (
-- Step 1: 从 Redis 中获取指定 QueryID 的上下文元数据。
-- `redis_meta` 是我们在 GitOps 中定义的 catalog。
-- `default.trino:query:meta` 对应 Redis 的 schema 和表 (key pattern)。
SELECT
CAST(json_extract(v, '$.queryId') AS VARCHAR) AS query_id,
CAST(json_extract(v, '$.user') AS VARCHAR) AS user,
CAST(json_extract(v, '$.source') AS VARCHAR) AS source,
CAST(json_extract(v, '$.businessBatchId') AS VARCHAR) AS business_batch_id,
from_iso8601_timestamp(CAST(json_extract(v, '$.createQueryTime') AS VARCHAR)) AS create_time
FROM redis_meta.default."trino:query:meta"
WHERE k = '20231027_083000_12345_abcde' -- 直接按 key 过滤,效率极高
),
trino_logs AS (
-- Step 2: 从 Loki 中检索与该 QueryID 相关的所有日志。
-- `loki` 是 catalog name. `default` 是 schema.
-- `"{app=\"trino-coordinator\"} or {app=\"trino-worker\"}"` 是 Loki 的流选择器 (stream selector)。
-- 我们只查询查询创建之后一小时内的日志,避免全表扫描。
SELECT
ts AS log_time,
-- 从原始日志行中提取关键信息,这里假设日志是 JSON 格式
json_extract_scalar(line, '$.thread') AS thread_name,
json_extract_scalar(line, '$.level') AS log_level,
json_extract_scalar(line, '$.message') AS message,
-- 从标签中提取 pod 名称,用于定位问题节点
map_extract_key(labels, 'pod') AS pod_name
FROM loki.default."{app=\"trino-coordinator\"} or {app=\"trino-worker\"}"
WHERE
line LIKE '%20231027_083000_12345_abcde%' -- 初步过滤,利用 Loki 的索引加速
AND ts BETWEEN (SELECT create_time FROM query_meta) AND (SELECT create_time FROM query_meta) + interval '1' hour
)
-- Step 3: 将元数据和日志数据连接起来,并进行分析。
SELECT
m.query_id,
m.user,
m.source,
m.business_batch_id,
l.pod_name,
l.log_time,
l.log_level,
l.message
FROM query_meta m
LEFT JOIN trino_logs l ON 1=1 -- 简单连接,因为日志已按 QueryID 过滤
WHERE
-- 过滤出我们关心的 WARN 或 ERROR 级别日志
l.log_level IN ('WARN', 'ERROR')
-- 或者查找特定的性能瓶颈信息,例如 GC 暂停或 Split 处理慢
OR l.message LIKE '%Full GC%'
OR l.message LIKE '%Split running for more than%'
ORDER BY l.log_time;
这条 SQL 查询的威力在于:
- 跨源关联:
JOIN
操作将 Redis 中结构化的元数据与 Loki 中半结构化的日志数据无缝连接。 - 上下文丰富: 我们立刻就能知道这个慢查询是由哪个用户、通过哪个工具、为哪个业务批次提交的。
- 精准定位: 结果直接显示了问题发生在哪个
pod
上,以及具体的警告或错误信息。 - 效率提升: 原本数小时的人工排查,现在变成了数秒或数分钟的 SQL 查询。
局限性与未来展望
这套体系虽然强大,但也并非银弹。
首先,Redis 作为元数据存储,其持久性和容量有限。对于需要长期追溯和分析查询性能趋势的场景,将 EventListener
的数据写入到一个更持久的存储,如 ClickHouse 或数据湖(例如 Apache Iceberg 表)中会是更好的选择。Trino 同样可以连接这些数据源,查询逻辑保持不变。
其次,EventListener
本身是一个需要维护的组件,它增加了系统的复杂性。如果 Trino 社区未来能够提供基于 OpenTelemetry 的原生事件导出,那么我们可以用更标准化的方式来捕获这些元数据,减少定制化开发。
最后,目前的诊断还主要依赖日志。下一步的演进方向是将第三大可观测性支柱——指标(Metrics)也纳入联邦查询的范畴。通过 trino-prometheus
连接器,我们可以将 Trino worker 的 CPU、内存、GC次数等指标与日志、元数据关联起来,提供一个更加立体化的故障诊断视图。例如,我们可以查询出在慢查询发生的时间窗口内,哪些 worker 节点的 GC 次数出现了异常飙升。
这个基于 GitOps 和联邦查询的诊断平台,彻底改变了我们团队的运维模式。它将繁琐的手动任务转化为可复用、可版本化的 SQL 查询,将工程师从救火的泥潭中解放出来,使其能更专注于性能优化和架构演进本身。