超级给力 Spring Boot + 阿里云 SLS 构建日志实时查询系统实践

文章目录

    • 一、前言
    • 二、系统架构总览
      • [2.1 整体架构](#2.1 整体架构)
      • [2.2 设计目标](#2.2 设计目标)
    • [三、SLS 索引设计------性能的基石](#三、SLS 索引设计——性能的基石)
      • [3.1 三种索引机制辨析](#3.1 三种索引机制辨析)
      • [3.2 三种方案对比](#3.2 三种方案对比)
      • [3.3 我们最终的选择:全文索引 + JSON 自动索引 + 手动修正](#3.3 我们最终的选择:全文索引 + JSON 自动索引 + 手动修正)
      • [3.4 通用字段索引(两种日志共用)](#3.4 通用字段索引(两种日志共用))
      • [3.5 结构化字段索引(仅埋点日志)](#3.5 结构化字段索引(仅埋点日志))
      • [3.6 索引类型选择说明](#3.6 索引类型选择说明)
    • 四、完整代码实现
      • [4.1 项目结构](#4.1 项目结构)
      • [4.2 Maven 依赖](#4.2 Maven 依赖)
      • [4.3 application.yml 配置](#4.3 application.yml 配置)
      • [4.4 SLS Client 配置类](#4.4 SLS Client 配置类)
      • [4.5 数据模型层](#4.5 数据模型层)
      • [4.6 查询服务------智能路由核心](#4.6 查询服务——智能路由核心)
      • [4.7 柱状图统计服务](#4.7 柱状图统计服务)
        • [SQL 分析原理](#SQL 分析原理)
      • [4.8 Controller 层](#4.8 Controller 层)
        • [日志查询 + 柱状图 Controller](#日志查询 + 柱状图 Controller)
        • [链路追踪 Controller](#链路追踪 Controller)
      • [4.9 工具类](#4.9 工具类)
    • 五、接口测试与响应数据
      • [5.1 通用日志查询](#5.1 通用日志查询)
      • [5.2 关键词全文检索](#5.2 关键词全文检索)
      • [5.3 关键词 + 多条件过滤](#5.3 关键词 + 多条件过滤)
      • [5.4 柱状图查询](#5.4 柱状图查询)
      • [5.5 一体化搜索(日志 + 柱状图一起查)](#5.5 一体化搜索(日志 + 柱状图一起查))
      • [5.6 Trace 链路查询](#5.6 Trace 链路查询)
      • [5.7 Trace 链路摘要](#5.7 Trace 链路摘要)
    • 六、关键设计要点
      • [6.1 混合日志兼容策略](#6.1 混合日志兼容策略)
      • [6.2 索引优化建议](#6.2 索引优化建议)
      • [6.3 分页策略](#6.3 分页策略)
      • [6.4 实时性保障](#6.4 实时性保障)
      • [6.5 查询场景速查表](#6.5 查询场景速查表)
    • 七、总结

一、前言

不知道大家有没有遇到过这种场景:业务线越来越多,日志格式五花八门,线上出了问题想查个日志,要么登录服务器 grep,要么去 SLS 控制台手写查询语句,效率低得让人头大。

我们团队维护了十几个微服务,日志大致分为两类:

  • 结构化埋点日志:包含完整的调用链信息(traceId、spanId、scene、duration_ms 等),由框架层自动埋点产出,用于链路追踪和性能分析。这类日志字段固定、格式统一,查询时通常按 traceId 聚合或按耗时排序。
  • 普通日志:字段不固定,可能就 message、timestamp、level 三件套,开发人员用在哪写到哪,没有统一的格式约束。

痛点很明显:两类日志写入同一个 SLS Logstore,但查询方式完全不同。结构化日志需要精确字段匹配,普通日志只能全文检索。如果分开两个 Logstore 管理,运维成本翻倍不说,联排问题时还得来回切换。

所以我们做了一个决定:写一个统一的查询服务,对外提供一套 REST API,内部智能路由到不同的查询策略,让调用方不用关心底层日志格式差异。

于是就有了这个项目------sls-log-query,一个 Spring Boot 3.5 + 阿里云 SLS SDK 的轻量查询服务,支持日志检索、Trace 链路追踪、柱状图统计三大核心能力。

本文会把从设计到落地的全过程掰开揉碎来讲,包括 SLS 索引配置、Java SDK 集成、查询路由策略、柱状图 SQL 分析、以及完整的接口测试示例。

二、系统架构总览

2.1 整体架构

先上架构图,一张图说清楚数据流和分层:

2.2 设计目标

目标 说明 实现方式
实时查询 日志写入后 1 秒内可检索 SLS 实时写入 + SDK 轮询
混合兼容 同时支持结构化日志和普通日志查询 全文索引 + 字段索引双轨制
链路追踪 按 traceId 聚合全链路日志 content.traceId:xxx 精确匹配
可视化 提供日志条数柱状图,支持时间粒度下钻 SLS SQL 分析 + 时间分桶聚合
高性能 利用 SLS 索引,避免全量扫描 字段级索引 + 时间窗口限制

三、SLS 索引设计------性能的基石

说实话,SLS 查询快不快,90% 取决于索引配得好不好。索引配好了查询就是毫秒级响应,配不好就是全量扫描分分钟超时。

这一节我们重点讨论 SLS 的三种索引机制:全文索引字段索引JSON 自动索引,以及我们项目最终选择了什么方案。

3.1 三种索引机制辨析

我们的日志统一以 JSON 格式写入 SLS 的 content 字段,一条典型的日志在 SLS 中存储为:

复制代码
__time__: 1690000000
__source__: 10.0.1.5
__topic__: 
content: {"timestamp":"2023-07-22T10:30:00Z","level":"ERROR","service":"order-service","message":"订单超时","traceId":"acb123def456","duration_ms":1520,"scene":"upstream_ingress"}

先理清三个概念,很多同学容易混:

全文索引

全文索引是 LogStore 级别的配置,对整个日志的所有 text 字段生效。开启后,SLS 会将日志内容的文本(包括 JSON 字符串内的 key 名和 value)拆词索引。它的核心能力是:不限定字段的关键词搜索

比如搜索 NullPointerException,SLS 会在所有日志的全文里匹配,不管这个关键词出现在 JSON 的哪个字段中(message、errorStack 还是别的字段),都能命中。

关于 content 字段类型 :虽然我们开启了全文索引,但 content 字段本身的类型是 json 而不是 text。设为 json 类型后,SLS 会解析 JSON 结构,支持对嵌套子字段建立字段索引(如 content.traceId:xxx)。同时全文索引仍然对 JSON 内的文本值生效,两者并不冲突。

字段索引

对某个具体字段单独建立索引(如 content.levelcontent.traceId),让 SLS 能够跳过全文扫描,精确定位到特定字段值。它的核心能力是:精准的字段级查询,性能和效率最高。

比如搜索 content.level:ERROR,SLS 直接定位到 level 字段的索引,不需要扫描整个 content 文本。

JSON 自动索引

这是 SLS 控制台提供的一个便捷功能------在查询分析页面点击自动生成索引后,SLS 会根据采集时预览数据的第一条内容自动创建字段索引,操作步骤为:

  1. 开启索引后,在查询分析页面点击自动生成索引
  2. SLS 解析预览日志样本中的顶层 key,为每个 key 自动创建 text 类型的字段索引
  3. 对于类型为 json 的字段,可以进一步手动配置嵌套子字段的索引

比如基于样本日志:

json 复制代码
{"message":"订单超时","traceId":"abc123","duration_ms":1520,"errorStack":"NullPointerException at xxx"}

控制台自动生成索引会创建以下字段索引:

自动创建的字段索引 类型 说明
content.message text 自动生成
content.traceId text 自动生成
content.errorStack text 自动生成
content.duration_ms text 自动生成,但类型不对!

关于嵌套字段 :控制台的自动生成索引只解析日志样本的顶层的 key ,不会递归解析深层嵌套结构。如果日志中有嵌套 JSON(例如 {"request":{"headers":{"x-request-id":"xxx"}}}),自动生成索引只会识别 content.request 这个字段,而不会自动展开 content.request.headers 及更深层。要索引嵌套字段,有三种方式:

  • 手动配置 :在控制台将 content 字段类型设为 json,然后手动添加嵌套子字段的索引
  • SDK/API 方式 :使用 IndexJsonKeyConfig 配置 index_all: true 并设置 max_depth(默认 -1 表示无深度限制),SLS 会自动递归索引 JSON 内所有字符串值,直到达到最大深度
  • 全文索引兜底:即使嵌套字段没有字段索引,全文索引也能保证关键词搜索可用
自动索引的局限性

自动索引虽然方便,但有三个明显的坑:

1. 数值类型默认配成 text,范围查询失效

这是最大的坑。duration_mscostamount 这类数值字段,自动创建索引时默认是 text 类型,导致:

复制代码
content.duration_ms > 1000   -- ❌ 不生效,text 类型不支持数值比较

你必须手动把这类字段从 text 改成 double/long。

2. 不替代全文索引,不限定字段的关键词搜索仍然查不到

自动索引创建的是字段索引 ,不是全文索引。区别在于:

场景 有全文索引 仅靠自动索引(字段索引)
搜索 NullPointerException ✅ 直接搜,全字段匹配 ❌ 必须指定 content.errorStack:NullPointerException
不确定关键词在哪个字段 ✅ 随便搜,都能命中 ❌ 搜不到
字段值里有 timeout 但不知道是哪个字段 ✅ 直接搜 timeout ❌ 必须知道是 message 还是 error 字段

3. 索引膨胀风险

如果日志里有些字段是动态变化的(比如 requestHeaders 里的各种 header),自动索引会为每个新出现的字段都创建索引,长时间运行下来索引数量会膨胀,增加存储成本和索引管理复杂度。

3.2 三种方案对比

配置方案 不限定字段关键词搜索 字段精确匹配 数值范围查询 新字段自动覆盖 索引维护成本
仅全文索引 ✅ 随便搜都能命中 ❌ 无字段索引,性能较低 ❌ 不支持数值语义 ✅ 自动覆盖
仅字段索引(手动配) ❌ 必须指定字段前缀 ✅ 性能最高 ✅ 手动配成 double/long ❌ 新增字段需手动配
仅 JSON 自动索引 ❌ 必须指定字段前缀 ✅ 自动创建 ❌ 默认 text,需手动改类型 ✅ 自动发现新字段
全文 + JSON 自动索引(我们的方案) ✅ 全文兜底 ✅ 自动索引精准定位 ✅ 手动修正数值类型 ✅ 双保险

3.3 我们最终的选择:全文索引 + JSON 自动索引 + 手动修正

我们最终的方案是:

  1. content 字段类型设为 json:让 SLS 解析 JSON 结构,支持嵌套子字段的字段索引

  2. 全文索引:开启 LogStore 级全文索引,用于不限定字段的关键词搜索兜底

  3. JSON 自动创建索引:开启,让 SLS 自动为 content 下的顶层子字段创建 text 索引

  4. 手动修正数值类型 :将 duration_ms 等数值字段从 text 改为 double

    content 字段配置:
    ├── 字段类型:json ✅(解析 JSON 结构,支持嵌套字段索引)
    ├── 开启自动创建索引 ✅
    │ └── 自动为顶层子字段创建 text 索引
    │ └── 手动修正:duration_ms → double,确保范围查询可用
    ├── 全文索引(LogStore 级)✅
    │ └── 用于无指定字段的关键词搜索
    ├── 字段索引优先级高于全文索引

之所以这么选,是因为我们的混合日志场景决定了:普通日志字段不固定,全靠手动配字段索引不现实;但光靠全文索引,字段级精确查询的性能又不够。两者互补,每种查询走最适合的路径。

3.4 通用字段索引(两种日志共用)

下面是实际配置的字段索引,不管结构化还是普通日志都会写入:

字段路径 类型 说明 查询示例
content.timestamp text ISO8601 时间 content.timestamp:"2023-07-22T10:*"
content.level text 日志级别 content.level:ERROR
content.message text 日志内容 content.message:*timeout*
content.host text 宿主机名 content.host:prod-node-01
content.service text 应用名 content.service:order-service
content.env text 环境标识 content.env:production

3.5 结构化字段索引(仅埋点日志)

这些字段只有埋点日志才有,普通日志写入时不存在这些字段,但查询时不会报错,只是匹配不到结果:

字段路径 类型 说明 查询示例
content.traceId text 全链路唯一标识 content.traceId:acb123def456
content.spanId text 当前处理阶段 content.spanId:span-001
content.parentSpanId text 上级 spanId content.parentSpanId:span-000
content.scene text 业务场景 content.scene:upstream_ingress
content.path text HTTP 路径/RPC 方法名 content.path:/api/orders
content.method text HTTP Method content.method:POST
content.duration_ms double 耗时(毫秒) content.duration_ms > 1000
content.error text 错误信息 content.error:*timeout*
content.errorStack text 错误堆栈 content.errorStack:NullPointer*

3.6 索引类型选择说明

类型 适用场景 查询能力
text 字符串字段 精确匹配、前缀查询、模糊匹配(*?
json 结构化 JSON 字段 解析 JSON 结构,支持声明嵌套子字段的索引
double 数值字段 范围查询(>, <, >=, <=
long 整数字段 范围查询、聚合计算

踩坑提醒duration_ms 一定要配成 doublelong,不要配成 text。配成 text 的话,> 1000 的范围查询不生效,SLS 会把它当字符串比较,结果完全不对。如果用了 JSON 自动索引,记得手动把数值字段从 text 改成 double。

四、完整代码实现

4.1 项目结构

复制代码
sls-log-query/
├── pom.xml
└── src/main/java/com/example/slslog/
    ├── SlsLogQueryApplication.java          # 启动类
    ├── config/
    │   └── SlsConfig.java                   # SLS Client 配置
    ├── controller/
    │   ├── LogQueryController.java          # 日志查询 + 柱状图 API
    │   └── TraceController.java             # 链路追踪 API
    ├── model/
    │   ├── LogQueryParam.java               # 统一查询参数
    │   ├── LogRecord.java                   # 日志记录模型
    │   ├── LogQueryResult.java              # 查询结果封装
    │   └── HistogramEntry.java              # 柱状图数据点
    ├── service/
    │   ├── LogQueryService.java             # 查询服务接口
    │   ├── LogHistogramService.java         # 柱状图服务接口
    │   └── impl/
    │       ├── LogQueryServiceImpl.java     # 查询服务实现
    │       └── LogHistogramServiceImpl.java # 柱状图服务实现
    └── util/
        └── JsonUtils.java                  # JSON 工具类

4.2 Maven 依赖

pom.xml 的核心依赖:

xml 复制代码
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.5.5</version>
</parent>

<groupId>com.example</groupId>
<artifactId>sls-log-query</artifactId>
<version>1.0.0</version>

<properties>
    <java.version>17</java.version>
    <aliyun-log.version>0.6.120</aliyun-log.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>com.aliyun.openservices</groupId>
        <artifactId>aliyun-log</artifactId>
        <version>${aliyun-log.version}</version>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-lang3</artifactId>
    </dependency>
</dependencies>

4.3 application.yml 配置

所有敏感信息全部走环境变量注入,杜绝硬编码:

yaml 复制代码
server:
  port: 8080

spring:
  application:
    name: sls-log-query

sls:
  endpoint: ${SLS_ENDPOINT}
  access-key-id: ${SLS_ACCESS_KEY_ID}
  access-key-secret: ${SLS_ACCESS_KEY_SECRET}
  project: ${SLS_PROJECT}
  logstore: ${SLS_LOGSTORE}
  connect-timeout: 10000
  read-timeout: 60000
  max-connections: 50

启动方式:

bash 复制代码
export SLS_ENDPOINT=your-log-endpoint
export SLS_ACCESS_KEY_ID=your-access-key
export SLS_ACCESS_KEY_SECRET=your-access-secret
export SLS_PROJECT=your-project
export SLS_LOGSTORE=your-logstore

mvn spring-boot:run

4.4 SLS Client 配置类

通过 @Configuration 注入单例的 SLS Client,设置了连接超时、读超时和最大连接数:

java 复制代码
/**
 * SlsConfig
 *
 * @author senfel
 * @date 2026/6/19 10:36
 */
@Configuration
public class SlsConfig {

    @Value("${sls.endpoint}")
    private String endpoint;

    @Value("${sls.access-key-id}")
    private String accessKeyId;

    @Value("${sls.access-key-secret}")
    private String accessKeySecret;

    @Value("${sls.project}")
    private String project;

    @Value("${sls.logstore}")
    private String logstore;

    @Value("${sls.connect-timeout:10000}")
    private int connectTimeout;

    @Value("${sls.read-timeout:60000}")
    private int readTimeout;

    @Value("${sls.max-connections:50}")
    private int maxConnections;

    public String getProject() {
        return project;
    }

    public String getLogstore() {
        return logstore;
    }

    @Bean
    public Client slsClient() {
        ClientConfiguration config = new ClientConfiguration();
        config.setConnectionTimeout(connectTimeout);
        config.setSocketTimeout(readTimeout);
        config.setMaxConnections(maxConnections);
        return new Client(endpoint, accessKeyId, accessKeySecret, "", config);
    }
}

配置参数说明:

参数 默认值 说明
connect-timeout 10000ms 建立连接超时
read-timeout 60000ms 读取数据超时,大查询要设置大一点
max-connections 50 连接池大小,按 QPS 估算

4.5 数据模型层

统一查询参数

LogQueryParam 兼顾两类日志的所有查询场景:

java 复制代码
/**
 * LogQueryParam
 *
 * @author senfel
 * @date 2026/6/19 10:37
 */
@Data
public class LogQueryParam {

    private String keyword;
    private String level;
    private String service;
    private String host;
    private String env;

    private String traceId;
    private String scene;
    private String path;
    private String method;
    private Double minDuration;
    private Double maxDuration;

    private Long from;
    private Long to;

    private Integer offset;
    private Integer line;

    private Integer intervalMinutes;
}

参数分组速查:

分组 参数 适用日志类型
通用过滤 keyword, level, service, host, env 两种日志
结构化查询 traceId, scene, path, method, minDuration, maxDuration 仅埋点日志
时间范围 from, to 必填
分页控制 offset, line 两种日志
柱状图 intervalMinutes 柱状图专用
日志记录模型

LogRecord 是核心数据模型,关键设计点是 structured 标记字段和 contentMap 原始数据兜底:

java 复制代码
/**
 * LogRecord
 *
 * @author senfel
 * @date 2026/6/19 10:37
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LogRecord {

    private String timestamp;
    private String level;
    private String message;
    private String host;
    private String service;
    private String env;

    private String traceId;
    private String spanId;
    private String parentSpanId;
    private String scene;
    private String path;
    private String method;
    private Double durationMs;
    private String error;

    private String rawJson;
    private Map<String, Object> contentMap;

    private boolean structured;
}

structured 字段的判定逻辑很简单:如果这条日志包含 traceId,就认为是结构化埋点日志;否则就是普通日志。前端拿到这个标识就知道要不要展示 spanId、duration_ms 等结构化字段。

查询结果封装
java 复制代码
/**
 * LogQueryResult
 *
 * @author senfel
 * @date 2026/6/19 10:37
 */
@Data
@Builder
public class LogQueryResult {

    private List<LogRecord> records;
    private long total;
    private int offset;
    private int line;
    private boolean isCompleted;
}

isCompleted 字段用来标识查询是否精确完成。SLS 在大数据量场景下可能返回部分结果(isCompleted = false),前端需要给用户提示"结果不精确,建议缩小时间范围"。

柱状图数据点
java 复制代码
/**
 * HistogramEntry
 *
 * @author senfel
 * @date 2026/6/19 10:36 
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class HistogramEntry {

    private long timestamp;
    private long count;
}

每个 HistogramEntry 代表一个时间分桶,timestamp 是分桶起始时间戳(秒),count 是该桶内的日志条数。前端 ECharts 直接映射为柱状图的 X 轴和 Y 轴。

4.6 查询服务------智能路由核心

LogQueryServiceImpl 是整个系统的灵魂,实现了三种查询策略的智能路由:

java 复制代码
/**
 * LogQueryServiceImpl
 *
 * @author senfel
 * @date 2026/6/19 10:38
 */
@Service
public class LogQueryServiceImpl implements LogQueryService {

    private static final Logger log = LoggerFactory.getLogger(LogQueryServiceImpl.class);

    private final Client slsClient;
    private final SlsConfig slsConfig;

    public LogQueryServiceImpl(Client slsClient, SlsConfig slsConfig) {
        this.slsClient = slsClient;
        this.slsConfig = slsConfig;
    }

    @Override
    public LogQueryResult query(LogQueryParam param) throws Exception {
        if (StringUtils.isNotBlank(param.getTraceId())) {
            return queryByTraceId(param.getTraceId(), param);
        } else if (StringUtils.isNotBlank(param.getKeyword())) {
            return queryByKeyword(param);
        } else {
            return queryByConditions(param);
        }
    }

    @Override
    public LogQueryResult queryByTraceId(String traceId, LogQueryParam param) throws Exception {
        LogQueryParam traceParam = new LogQueryParam();
        traceParam.setFrom(param.getFrom() != null ? param.getFrom() : (System.currentTimeMillis() / 1000 - 1800));
        traceParam.setTo(param.getTo() != null ? param.getTo() : (System.currentTimeMillis() / 1000));
        traceParam.setOffset(0);
        traceParam.setLine(100);

        String query = "content.traceId:" + traceId;
        LogQueryResult result = executeQuery(query, traceParam, false);

        result.getRecords().sort(Comparator.comparing(LogRecord::getTimestamp,
                Comparator.nullsFirst(Comparator.naturalOrder())));

        return LogQueryResult.builder()
                .records(result.getRecords())
                .total(result.getTotal())
                .isCompleted(result.isCompleted())
                .build();
    }

    @Override
    public LogQueryResult queryByKeyword(LogQueryParam param) throws Exception {
        List<String> conditions = new ArrayList<>();
        conditions.add(param.getKeyword());

        if (StringUtils.isNotBlank(param.getService())) {
            conditions.add("content.service:" + param.getService());
        }
        if (StringUtils.isNotBlank(param.getLevel())) {
            conditions.add("content.level:" + param.getLevel());
        }
        if (StringUtils.isNotBlank(param.getHost())) {
            conditions.add("content.host:" + param.getHost());
        }
        if (StringUtils.isNotBlank(param.getEnv())) {
            conditions.add("content.env:" + param.getEnv());
        }
        if (StringUtils.isNotBlank(param.getScene())) {
            conditions.add("content.scene:" + param.getScene());
        }
        if (StringUtils.isNotBlank(param.getPath())) {
            conditions.add("content.path:" + param.getPath());
        }

        String query = String.join(" AND ", conditions);
        return executeQuery(query, param, true);
    }

    @Override
    public LogQueryResult queryByConditions(LogQueryParam param) throws Exception {
        List<String> conditions = new ArrayList<>();

        if (StringUtils.isNotBlank(param.getLevel())) {
            conditions.add("content.level:" + param.getLevel());
        }
        if (StringUtils.isNotBlank(param.getService())) {
            conditions.add("content.service:" + param.getService());
        }
        if (StringUtils.isNotBlank(param.getHost())) {
            conditions.add("content.host:" + param.getHost());
        }
        if (StringUtils.isNotBlank(param.getEnv())) {
            conditions.add("content.env:" + param.getEnv());
        }
        if (StringUtils.isNotBlank(param.getScene())) {
            conditions.add("content.scene:" + param.getScene());
        }
        if (StringUtils.isNotBlank(param.getPath())) {
            conditions.add("content.path:" + param.getPath());
        }

        if (param.getMinDuration() != null) {
            conditions.add("content.duration_ms > " + param.getMinDuration());
        }
        if (param.getMaxDuration() != null) {
            conditions.add("content.duration_ms < " + param.getMaxDuration());
        }

        String query = conditions.isEmpty() ? "*" : String.join(" AND ", conditions);
        return executeQuery(query, param, true);
    }

    private LogQueryResult executeQuery(String query, LogQueryParam param, boolean reverse) throws Exception {
        int from = getFrom(param);
        int to = getTo(param);
        int offset = param.getOffset() != null ? param.getOffset() : 0;
        int line = Math.min(param.getLine() != null ? param.getLine() : 100, 100);

        GetLogsResponse response = null;
        Exception lastException = null;
        for (int retry = 0; retry < 3; retry++) {
            if (retry > 0) {
                Thread.sleep((long) Math.pow(2, retry) * 1000);
            }
            try {
                GetLogsRequest request = new GetLogsRequest(
                        slsConfig.getProject(),
                        slsConfig.getLogstore(),
                        from, to, "", query, offset, line, reverse
                );
                response = slsClient.GetLogs(request);
                if (response != null && response.IsCompleted()) {
                    break;
                }
            } catch (Exception e) {
                lastException = e;
                log.warn("SLS query attempt {} failed, will retry: {}", retry + 1, e.getMessage());
            }
        }

        if (response == null) {
            throw new RuntimeException("SLS query failed after 3 retries, query=" + query, lastException);
        }

        return LogQueryResult.builder()
                .records(parseRecords(response))
                .total(response.GetCount())
                .offset(offset)
                .line(line)
                .isCompleted(response.IsCompleted())
                .build();
    }

    private List<LogRecord> parseRecords(GetLogsResponse response) {
        List<LogRecord> records = new ArrayList<>();
        for (QueriedLog queriedLog : response.getLogs()) {
            LogItem item = queriedLog.GetLogItem();
            LogRecord.LogRecordBuilder builder = LogRecord.builder();

            for (LogContent content : item.mContents) {
                String key = content.mKey;
                String value = content.mValue;

                switch (key) {
                    case "content.timestamp":
                        builder.timestamp(value);
                        break;
                    case "content.level":
                        builder.level(value);
                        break;
                    case "content.traceId":
                        builder.traceId(value);
                        break;
                    case "content.spanId":
                        builder.spanId(value);
                        break;
                    case "content.parentSpanId":
                        builder.parentSpanId(value);
                        break;
                    case "content.scene":
                        builder.scene(value);
                        break;
                    case "content.path":
                        builder.path(value);
                        break;
                    case "content.method":
                        builder.method(value);
                        break;
                    case "content.duration_ms":
                        try {
                            builder.durationMs(Double.parseDouble(value));
                        } catch (Exception e) {
                            builder.durationMs(null);
                        }
                        break;
                    case "content.message":
                        builder.message(value);
                        break;
                    case "content.host":
                        builder.host(value);
                        break;
                    case "content.service":
                        builder.service(value);
                        break;
                    case "content.env":
                        builder.env(value);
                        break;
                    case "content.error":
                        builder.error(value);
                        break;
                    case "content":
                        builder.rawJson(value);
                        Map<String, Object> contentMap = JsonUtils.parseMap(value);
                        builder.contentMap(contentMap);
                        if (contentMap != null) {
                            Object ts = contentMap.get("timestamp");
                            if (ts != null) builder.timestamp(String.valueOf(ts));
                            Object lv = contentMap.get("level");
                            if (lv != null) builder.level(String.valueOf(lv));
                            Object msg = contentMap.get("message");
                            if (msg != null) builder.message(String.valueOf(msg));
                            Object host = contentMap.get("host");
                            if (host != null) builder.host(String.valueOf(host));
                            Object svc = contentMap.get("service");
                            if (svc != null) builder.service(String.valueOf(svc));
                            Object env = contentMap.get("env");
                            if (env != null) builder.env(String.valueOf(env));
                            Object traceId = contentMap.get("traceId");
                            if (traceId != null) builder.traceId(String.valueOf(traceId));
                            Object spanId = contentMap.get("spanId");
                            if (spanId != null) builder.spanId(String.valueOf(spanId));
                            Object parentSpanId = contentMap.get("parentSpanId");
                            if (parentSpanId != null) builder.parentSpanId(String.valueOf(parentSpanId));
                            Object scene = contentMap.get("scene");
                            if (scene != null) builder.scene(String.valueOf(scene));
                            Object path = contentMap.get("path");
                            if (path != null) builder.path(String.valueOf(path));
                            Object method = contentMap.get("method");
                            if (method != null) builder.method(String.valueOf(method));
                            Object dur = contentMap.get("duration_ms");
                            if (dur != null) {
                                try {
                                    builder.durationMs(Double.parseDouble(String.valueOf(dur)));
                                } catch (Exception e) {
                                    builder.durationMs(null);
                                }
                            }
                            Object err = contentMap.get("error");
                            if (err != null) builder.error(String.valueOf(err));
                        }
                        break;
                }
            }

            LogRecord record = builder.build();
            record.setStructured(record.getTraceId() != null);
            records.add(record);
        }
        return records;
    }

    private int getFrom(LogQueryParam param) {
        return (int) (param.getFrom() != null ? param.getFrom() : System.currentTimeMillis() / 1000 - 3600);
    }

    private int getTo(LogQueryParam param) {
        return (int) (param.getTo() != null ? param.getTo() : System.currentTimeMillis() / 1000);
    }
}
智能路由逻辑详解

核心在于 query() 方法的优先级路由:

复制代码
用户请求参数
    │
    ├── 有 traceId? ──→ queryByTraceId() 链路追踪
    │                    生成 "content.traceId:xxx" 查询语句
    │                    结果按时间戳升序排列
    │
    ├── 有关键词? ──→ queryByKeyword() 全文检索
    │                    关键词 + 可选过滤条件 AND 拼接
    │                    兼容普通日志全文搜索
    │
    └── 其他条件 ──→ queryByConditions() 条件组合
                        通用条件 + 结构化条件自由组合
                        支持 duration_ms 范围查询
响应解析中的两层兜底策略

parseRecords() 方法做了两层解析:

  1. 字段直接映射 :SLS 返回的 content.xxx 字段直接赋值到 LogRecord 对应属性
  2. JSON 原始解析 :同时解析 content 字段的完整 JSON 字符串,提取所有字段到 contentMap

这样做的好处是:即使某个字段没有配置字段索引(比如 extra.someField),也能通过 contentMap 拿到原始数据,前端可以自由展示。

重试机制

executeQuery() 中的重试逻辑采用指数退避策略:

重试次数 等待时间 说明
第 1 次 0 直接请求
第 2 次 4 秒 2^2 = 4s
第 3 次 8 秒 2^3 = 8s

每次重试前检查 IsCompleted(),如果返回完整结果就直接返回,不需要等到 3 次都跑完。

4.7 柱状图统计服务

LogHistogramServiceImpl 利用 SLS 的 SQL 分析能力实现时间维度聚合:

java 复制代码
/**
 * LogHistogramServiceImpl
 *
 * @author senfel
 * @date 2026/6/19 10:37
 */
@Service
public class LogHistogramServiceImpl implements LogHistogramService {

    private static final Logger log = LoggerFactory.getLogger(LogHistogramServiceImpl.class);

    private final Client slsClient;
    private final SlsConfig slsConfig;

    public LogHistogramServiceImpl(Client slsClient, SlsConfig slsConfig) {
        this.slsClient = slsClient;
        this.slsConfig = slsConfig;
    }

    @Override
    public List<HistogramEntry> queryHistogram(LogQueryParam param) throws Exception {
        int from = getFrom(param);
        int to = getTo(param);
        int interval = param.getIntervalMinutes() != null ? param.getIntervalMinutes() : 1;

        String searchQuery = buildSearchQuery(param);
        String sql = String.format(
                "%s | SELECT __time__ - __time__ %% %d AS t, COUNT(*) AS cnt " +
                        "FROM log " +
                        "GROUP BY t " +
                        "ORDER BY t " +
                        "LIMIT 1000",
                searchQuery,
                interval * 60
        );

        GetLogsRequest request = new GetLogsRequest(
                slsConfig.getProject(),
                slsConfig.getLogstore(),
                from, to, "", sql
        );

        GetLogsResponse response = slsClient.GetLogs(request);
        List<HistogramEntry> entries = new ArrayList<>();

        if (response != null) {
            for (QueriedLog queriedLog : response.getLogs()) {
                LogItem item = queriedLog.GetLogItem();
                String colT = null;
                String colCnt = null;
                for (LogContent lc : item.mContents) {
                    if ("t".equals(lc.mKey)) {
                        colT = lc.mValue;
                    } else if ("cnt".equals(lc.mKey)) {
                        colCnt = lc.mValue;
                    }
                }
                if (colT != null && colCnt != null) {
                    long timestamp = Long.parseLong(colT);
                    long count = Long.parseLong(colCnt);
                    entries.add(new HistogramEntry(timestamp, count));
                }
            }
        }

        return entries;
    }

    private String buildSearchQuery(LogQueryParam param) {
        List<String> conditions = new ArrayList<>();

        if (param.getKeyword() != null) {
            conditions.add(param.getKeyword());
        }
        if (param.getLevel() != null) {
            conditions.add("content.level:" + param.getLevel());
        }
        if (param.getService() != null) {
            conditions.add("content.service:" + param.getService());
        }
        if (param.getHost() != null) {
            conditions.add("content.host:" + param.getHost());
        }
        if (param.getEnv() != null) {
            conditions.add("content.env:" + param.getEnv());
        }
        if (param.getScene() != null) {
            conditions.add("content.scene:" + param.getScene());
        }
        if (param.getPath() != null) {
            conditions.add("content.path:" + param.getPath());
        }
        if (param.getTraceId() != null) {
            conditions.add("content.traceId:" + param.getTraceId());
        }
        if (param.getMinDuration() != null) {
            conditions.add("content.duration_ms > " + param.getMinDuration());
        }
        if (param.getMaxDuration() != null) {
            conditions.add("content.duration_ms < " + param.getMaxDuration());
        }

        if (conditions.isEmpty()) {
            return "*";
        }
        return String.join(" AND ", conditions);
    }

    private int getFrom(LogQueryParam param) {
        return (int) (param.getFrom() != null ? param.getFrom() : System.currentTimeMillis() / 1000 - 21600);
    }

    private int getTo(LogQueryParam param) {
        return (int) (param.getTo() != null ? param.getTo() : System.currentTimeMillis() / 1000);
    }
}
SQL 分析原理

生成的 SQL 类似这样:

sql 复制代码
* | SELECT __time__ - __time__ % 60 AS t, COUNT(*) AS cnt 
    FROM log 
    GROUP BY t 
    ORDER BY t 
    LIMIT 1000

核心技巧是 __time__ - __time__ % {intervalSeconds}

  • __time__ 是 SLS 自带的 Unix 时间戳(秒)
  • % intervalSeconds 取模得到该桶内的偏移量
  • 两者相减得到桶的起始时间戳

举例说明:假设 intervalSeconds = 60(1分钟),时间戳 1690000050:

  • 1690000050 - 1690000050 % 60 = 1690000050 - 30 = 1690000020
  • 所有落在 [1690000020, 1690000080) 范围内的日志都被归到同一个桶

柱状图粒度建议:

时间范围 推荐粒度 桶数量
1 小时内 1 分钟 ≤ 60
6 小时内 5 分钟 ≤ 72
24 小时内 30 分钟 ≤ 48
7 天内 6 小时 ≤ 28

4.8 Controller 层

日志查询 + 柱状图 Controller

LogQueryController 提供了三个接口:纯日志查询、纯柱状图、以及一体化搜索:

java 复制代码
/**
 * LogQueryController
 *
 * @author senfel
 * @date 2026/6/19 10:29
 */
@RestController
@RequestMapping("/api/logs")
public class LogQueryController {

    private final LogQueryService logQueryService;
    private final LogHistogramService logHistogramService;

    public LogQueryController(LogQueryService logQueryService, LogHistogramService logHistogramService) {
        this.logQueryService = logQueryService;
        this.logHistogramService = logHistogramService;
    }

    /**
     * 通用日志查询
     */
    @GetMapping("/query")
    public ResponseEntity<LogQueryResult> query(LogQueryParam param) throws Exception {
        LogQueryResult result = logQueryService.query(param);
        return ResponseEntity.ok(result);
    }

    /**
     * 柱状图统计
     */
    @GetMapping("/histogram")
    public ResponseEntity<List<HistogramEntry>> histogram(LogQueryParam param) throws Exception {
        List<HistogramEntry> entries = logHistogramService.queryHistogram(param);
        return ResponseEntity.ok(entries);
    }

    /**
     * 一体化搜索:同时返回日志列表和柱状图
     */
    @GetMapping("/search")
    public ResponseEntity<Map<String, Object>> searchWithHistogram(LogQueryParam param) throws Exception {
        long start = System.currentTimeMillis();

        LogQueryResult logs = logQueryService.query(param);
        List<HistogramEntry> histogram = logHistogramService.queryHistogram(param);

        long elapsed = System.currentTimeMillis() - start;

        Map<String, Object> response = new HashMap<>();
        response.put("logs", logs);
        response.put("histogram", histogram);
        response.put("elapsedMs", elapsed);

        return ResponseEntity.ok(response);
    }
}
链路追踪 Controller

TraceController 提供链路日志查询和链路摘要两个接口:

java 复制代码
/**
 * TraceController
 *
 * @author senfel
 * @date 2026/6/19 10:32
 */
@RestController
@RequestMapping("/api/trace")
public class TraceController {

    private final LogQueryService logQueryService;

    public TraceController(LogQueryService logQueryService) {
        this.logQueryService = logQueryService;
    }

    /**
     * 按 traceId 查询全链路日志
     */
    @GetMapping("/{traceId}")
    public ResponseEntity<LogQueryResult> getTrace(
            @PathVariable String traceId,
            @RequestParam(required = false) Long from,
            @RequestParam(required = false) Long to) throws Exception {
        LogQueryParam param = new LogQueryParam();
        param.setFrom(from);
        param.setTo(to);
        LogQueryResult result = logQueryService.queryByTraceId(traceId, param);
        return ResponseEntity.ok(result);
    }

    /**
     * 查询 Trace 链路摘要(含统计信息)
     */
    @GetMapping("/{traceId}/summary")
    public ResponseEntity<Map<String, Object>> getTraceSummary(
            @PathVariable String traceId,
            @RequestParam(required = false) Long from,
            @RequestParam(required = false) Long to) throws Exception {
        LogQueryParam param = new LogQueryParam();
        param.setFrom(from);
        param.setTo(to);
        LogQueryResult result = logQueryService.queryByTraceId(traceId, param);

        List<String> services = result.getRecords().stream()
                .map(r -> r.getService())
                .filter(s -> s != null)
                .distinct()
                .toList();

        double totalDuration = result.getRecords().stream()
                .filter(r -> r.getDurationMs() != null)
                .mapToDouble(r -> r.getDurationMs())
                .sum();

        Map<String, Object> summary = new HashMap<>();
        summary.put("traceId", traceId);
        summary.put("spanCount", result.getRecords().size());
        summary.put("services", services);
        summary.put("totalDurationMs", totalDuration);
        summary.put("records", result.getRecords());

        return ResponseEntity.ok(summary);
    }
}

4.9 工具类

java 复制代码
/**
 * JsonUtils
 *
 * @author senfel
 * @date 2026/6/19 10:36
 */
public class JsonUtils {

    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    private JsonUtils() {
    }

    public static Map<String, Object> parseMap(String json) {
        try {
            return OBJECT_MAPPER.readValue(json, new TypeReference<Map<String, Object>>() {
            });
        } catch (JsonProcessingException e) {
            return Collections.emptyMap();
        }
    }

    public static String toJson(Object value) {
        try {
            return OBJECT_MAPPER.writeValueAsString(value);
        } catch (JsonProcessingException e) {
            return "";
        }
    }
}

五、接口测试与响应数据

下面用 curl 演示实际接口调用,并附上完整的 JSON 响应。

5.1 通用日志查询

请求:查询 order-service 的 ERROR 级别日志

bash 复制代码
curl -s "http://localhost:8080/api/logs/query?service=order-service&level=ERROR&from=1690000000&to=1690086400&offset=0&line=10" | jq

响应

json 复制代码
{
  "records": [
    {
      "timestamp": "2023-07-22T10:30:00Z",
      "level": "ERROR",
      "message": "订单超时,订单ID: 202307221030001",
      "host": "prod-node-01",
      "service": "order-service",
      "env": "production",
      "traceId": "acb123def456",
      "spanId": "span-003",
      "parentSpanId": "span-001",
      "scene": "upstream_ingress",
      "path": "/api/orders/create",
      "method": "POST",
      "durationMs": 1520.0,
      "error": "上游服务响应超时",
      "structured": true,
      "contentMap": {
        "timestamp": "2023-07-22T10:30:00Z",
        "level": "ERROR",
        "message": "订单超时,订单ID: 202307221030001",
        "service": "order-service",
        "traceId": "acb123def456",
        "duration_ms": 1520
      }
    },
    {
      "timestamp": "2023-07-22T10:25:00Z",
      "level": "ERROR",
      "message": "数据库连接超时",
      "host": "prod-node-01",
      "service": "order-service",
      "env": "production",
      "structured": false,
      "contentMap": {
        "timestamp": "2023-07-22T10:25:00Z",
        "level": "ERROR",
        "message": "数据库连接超时",
        "service": "order-service"
      }
    }
  ],
  "total": 2,
  "offset": 0,
  "line": 10,
  "isCompleted": true
}

注意看 structured 字段:第一条是 true(有 traceId),第二条是 false(没有 traceId,普通日志),前端可以根据这个标识来决定展示哪些字段列。

5.2 关键词全文检索

请求:搜索所有包含 "NullPointerException" 的日志

bash 复制代码
curl -s "http://localhost:8080/api/logs/query?keyword=NullPointerException&from=1690000000&to=1690086400&line=20" | jq

响应

json 复制代码
{
  "records": [
    {
      "timestamp": "2023-07-22T09:15:30Z",
      "level": "ERROR",
      "message": "java.lang.NullPointerException: Cannot invoke method on null object",
      "host": "prod-node-03",
      "service": "user-service",
      "env": "production",
      "traceId": "xyz789abc012",
      "spanId": "span-005",
      "scene": "user_login",
      "path": "/api/user/info",
      "method": "GET",
      "durationMs": 45.0,
      "error": "NullPointerException",
      "errorStack": "java.lang.NullPointerException\n\tat com.example.user.UserServiceImpl.getUserInfo(UserServiceImpl.java:85)\n\tat sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)",
      "structured": true,
      "contentMap": {
        "timestamp": "2023-07-22T09:15:30Z",
        "level": "ERROR",
        "message": "java.lang.NullPointerException: Cannot invoke method on null object",
        "service": "user-service",
        "traceId": "xyz789abc012",
        "errorStack": "java.lang.NullPointerException\n\tat com.example.user..."
      }
    }
  ],
  "total": 1,
  "offset": 0,
  "line": 20,
  "isCompleted": true
}

关键词全文检索对普通日志尤其有用------普通日志没有 traceId 之类的结构化字段,但通过全文索引,只要消息体里匹配到关键词就能搜出来。

5.3 关键词 + 多条件过滤

请求:搜索 ERROR 级别且耗时超过 1000ms 的日志

bash 复制代码
curl -s "http://localhost:8080/api/logs/query?level=ERROR&minDuration=1000&from=1690000000&to=1690086400" | jq

内部构建的 SLS 查询语句

复制代码
content.level:ERROR AND content.duration_ms > 1000

5.4 柱状图查询

请求:查询 6 小时内的日志分布,按 30 分钟粒度聚合

bash 复制代码
curl -s "http://localhost:8080/api/logs/histogram?from=1690000000&to=1690086400&intervalMinutes=30&level=ERROR" | jq

响应

json 复制代码
[
  {
    "timestamp": 1690000200,
    "count": 5
  },
  {
    "timestamp": 1690002000,
    "count": 12
  },
  {
    "timestamp": 1690003800,
    "count": 8
  },
  {
    "timestamp": 1690005600,
    "count": 23
  },
  {
    "timestamp": 1690007400,
    "count": 3
  }
]

前端拿到这个数据结构后,ECharts 的配置非常简洁:

javascript 复制代码
option = {
    xAxis: {
        type: 'category',
        data: data.map(d => new Date(d.timestamp * 1000).toLocaleTimeString())
    },
    yAxis: { type: 'value' },
    series: [{
        type: 'bar',
        data: data.map(d => d.count)
    }]
};

从响应数据可以直观看到,1690005600(2023-07-22 10:00)这个时段出现了一个异常峰值(23 条),运维同学可以点击这个柱状图下钻到该时间段的日志明细。

5.5 一体化搜索(日志 + 柱状图一起查)

请求:一次请求同时获取日志列表和柱状图

bash 复制代码
curl -s "http://localhost:8080/api/logs/search?service=order-service&level=ERROR&from=1690000000&to=1690086400&intervalMinutes=30" | jq

响应

json 复制代码
{
  "logs": {
    "records": [
      {
        "timestamp": "2023-07-22T10:30:00Z",
        "level": "ERROR",
        "message": "订单超时,订单ID: 202307221030001",
        "service": "order-service",
        "structured": true,
        "durationMs": 1520.0
      }
    ],
    "total": 1,
    "offset": 0,
    "line": 100,
    "isCompleted": true
  },
  "histogram": [
    { "timestamp": 1690000200, "count": 2 },
    { "timestamp": 1690002000, "count": 5 },
    { "timestamp": 1690003800, "count": 1 }
  ],
  "elapsedMs": 234
}

elapsedMs 字段返回了总耗时(234ms),前端可以展示给用户或者做性能监控。这个接口适合页面首次加载时一次性获取所有数据。

5.6 Trace 链路查询

请求:查询指定 traceId 的全链路日志

bash 复制代码
curl -s "http://localhost:8080/api/trace/acb123def456?from=1690000000&to=1690086400" | jq

响应

json 复制代码
{
  "records": [
    {
      "timestamp": "2023-07-22T10:29:50Z",
      "service": "gateway",
      "traceId": "acb123def456",
      "spanId": "span-000",
      "parentSpanId": null,
      "scene": "http_ingress",
      "path": "/api/orders/create",
      "method": "POST",
      "durationMs": 1520.0
    },
    {
      "timestamp": "2023-07-22T10:29:51Z",
      "service": "order-service",
      "traceId": "acb123def456",
      "spanId": "span-001",
      "parentSpanId": "span-000",
      "scene": "upstream_ingress",
      "path": "/api/orders/create",
      "method": "POST",
      "durationMs": 1500.0
    },
    {
      "timestamp": "2023-07-22T10:29:52Z",
      "service": "payment-service",
      "traceId": "acb123def456",
      "spanId": "span-002",
      "parentSpanId": "span-001",
      "scene": "payment_process",
      "durationMs": 1420.0
    }
  ],
  "total": 3,
  "offset": 0,
  "line": 100,
  "isCompleted": true
}

从结果可以还原整个调用链路:

复制代码
gateway (span-000, 1520ms)
  └── order-service (span-001, 1500ms)
        └── payment-service (span-002, 1420ms)

一眼就能看出慢在哪里------payment-service 占了 1420ms,是整个请求的瓶颈。

5.7 Trace 链路摘要

请求:查询链路摘要(含统计信息)

bash 复制代码
curl -s "http://localhost:8080/api/trace/acb123def456/summary?from=1690000000&to=1690086400" | jq

响应

json 复制代码
{
  "traceId": "acb123def456",
  "spanCount": 3,
  "services": ["gateway", "order-service", "payment-service"],
  "totalDurationMs": 4440.0,
  "records": [
    {
      "timestamp": "2023-07-22T10:29:50Z",
      "service": "gateway",
      "spanId": "span-000",
      "durationMs": 1520.0
    },
    {
      "timestamp": "2023-07-22T10:29:51Z",
      "service": "order-service",
      "spanId": "span-001",
      "durationMs": 1500.0
    },
    {
      "timestamp": "2023-07-22T10:29:52Z",
      "service": "payment-service",
      "spanId": "span-002",
      "durationMs": 1420.0
    }
  ]
}

摘要接口特别适合做 Trace 详情页的顶部概览------一眼看到涉及多少个服务、总耗时多少,再往下展开看明细。

六、关键设计要点

6.1 混合日志兼容策略

这是项目最核心的挑战,也是花时间最多的部分。最终方案总结如下:

策略 实现方式 原理
全文检索兜底 keyword 直接放入 SLS 查询语句 利用 LogStore 级全文索引,不依赖字段索引
字段查询容错 content.traceId:xxx,不存在的字段自然无结果 SLS 不会因为字段不存在而报错
聚合兼容 SQL 中使用 COALESCE(content.service, 'unknown') 空值自动填充默认值
类型自动识别 Java 代码判断 traceId 是否存在 record.setStructured(traceId != null)
原始数据兜底 contentMap 保留完整 JSON 前端可以拿到所有原始字段

6.2 索引优化建议

查询场景 索引建议 原因
level 过滤 建 text 索引 精确匹配,无索引则全量扫描
traceId 查询 建 text 索引 链路追踪的核心字段
duration_ms 范围查询 建 double 索引 > < 范围查询必须数值索引
message 模糊查询 建 text 索引 支持 *timeout* 通配符
耗时排序 duration_ms 建 double 索引 排序操作需要字段索引

6.3 分页策略

场景 分页方式 说明
纯查询语句 offset + line line 最大 100,超了会被截断
SQL 分析语句 LIMIT offset, line 建议每次 500~1000
翻页结束判断 IsCompleted() && count == 0 必须检查 IsCompleted()

6.4 实时性保障

措施 说明
SLS 实时写入 日志写入后 1 秒内可查询(SLS 保证)
索引配置 所有查询字段建立索引,避免全量扫描
时间窗口 默认查询最近 6 小时,限制扫描范围
重试机制 指数退避重试,最多 3 次,应对网络抖动
超时控制 读超时 60 秒,大查询不会无限等待
连接池 50 个连接,复用 TCP 连接减少开销

6.5 查询场景速查表

场景 接口 参数示例 对应日志类型
Trace 链路 GET /api/trace/{traceId} traceId=acb123def456 结构化
服务 ERROR GET /api/logs/query service=order-service&level=ERROR 通用
全文关键词 GET /api/logs/query keyword=NullPointerException 通用
慢查询 GET /api/logs/query minDuration=1000&level=ERROR 结构化
特定路径 GET /api/logs/query path=/api/orders 结构化
普通日志全文 GET /api/logs/query keyword=数据库连接超时 普通日志
柱状图趋势 GET /api/logs/histogram intervalMinutes=30 通用
登录页面 GET /api/logs/search 一体化搜索 通用

七、总结

项目整体做下来,核心收获有三点:

1. SLS 索引是性能的命门。 字段级索引配得好,查询就是毫秒级;配不好就是全表扫描,等得你想砸键盘。尤其要注意 double 类型和 text 类型的区别,数值范围查询必须用数值索引。

2. 混合日志兼容并不复杂。 核心思路是"宽容设计"------不强制字段存在,查询时自然过滤,代码里兜底处理。全文索引 + 字段索引双管齐下,两种日志都能高效检索。JSON 原始数据的 contentMap 兜底策略,保证了即使未来新增字段也能无缝展示。

3. 柱状图 + 日志列表的交互模式 真的很好用。运维同学先看趋势图定位异常时间段,再点击柱状图下钻到具体日志明细,排查效率比纯列表翻页高了一个档次。从测试数据来看,一次一体化搜索请求在 200~300ms 内就能同时返回日志列表和柱状图数据,体验相当流畅。

适用场景

  • 微服务架构下的统一日志查询平台
  • 需要链路追踪能力的业务系统
  • 运维监控、故障排查场景
  • 多环境(生产、预发、测试)日志统一管理

后续优化方向

  • TraceId 结果缓存:同一链路短时间内反复查看,缓存 30 秒避免重复查 SLS
  • 异步写入:接入消息队列做异步日志写入,避免日志量突增时打满 SLS 写入 QPS
  • 多维聚合:柱状图支持按 level、service 分组堆叠展示,一个图表里看到各服务的错误分布
  • 告警集成:柱状图数据对接告警规则,日志量突增时自动触发告警

感谢各位看官的一路陪伴,这篇文章从设计文档、完整代码到接口测试数据都梳理了一遍,希望能对正在建设日志平台的你有所帮助。有任何问题或者更好的思路,欢迎留言交流!