跳到主要内容

架构设计

了解 dlz-lens 的内部工作原理,帮助你更好地理解它的能力边界和性能表现。

整体架构

┌──────────────────────────────────────────────────────────────┐
│ 业务应用进程 │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Spring Boot 应用 │ │
│ │ │ │
│ │ Controller → Service → DAO → MyBatis → JDBC → DB │ │
│ │ ↓ │ │
│ │ RedisTemplate → Redis │ │
│ │ ↓ │ │
│ │ RestTemplate → HTTP │ │
│ └──────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ dlz-lens-core │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │ TraceCtx │ │ Caller │ │ CallCollector │ │ │
│ │ │ (Thread │ │ Resolver │ │ (异步队列) │ │ │
│ │ │ Local) │ │ │ │ │ │ │
│ │ └──────────┘ └──────────┘ └────────┬─────────┘ │ │
│ │ │ │ │
│ │ ┌────────▼─────────┐ │ │
│ │ │ InMemoryStorage │ │ │
│ │ │ (环形缓冲区) │ │ │
│ │ └────────┬─────────┘ │ │
│ └───────────────────────────────────────┼──────────────┘ │
│ │ │
│ ┌───────────────────────────────────────▼──────────────┐ │
│ │ dlz-lens-console │ │
│ │ │ │
│ │ REST API ─────┐ │ │
│ │ ├──── WebSocket ←→ 浏览器前端 │ │
│ │ Login ───────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘

模块划分

模块职责依赖
dlz-lens-core核心模型、存储、采集器、Trace上下文、调用者解析无(纯核心)
dlz-lens-sqlJDBC 代理(DataSource/Connection/Statement)core
dlz-lens-mybatisMyBatis 拦截器(Mapper定位增强)core, mybatis
dlz-lens-redisRedis 命令采集(Lettuce/Jedis 代理)core
dlz-lens-httpHTTP 调用采集(RestTemplate/WebClient/Feign)core
dlz-lens-springSpring 集成(WebTraceFilter、DataSource自动包装)core, jdbc, spring-web
dlz-lens-consoleWeb 控制台(REST API + WebSocket + 前端 + AutoConfig)core, spring

依赖关系

console → spring → sql → core
↘ mybatis ↗
↘ redis
↘ http

用户只需要引入 dlz-lens-console,其他模块通过传递依赖自动引入。

核心设计原则

原则 1:业务线程只做最必要的事

这是性能设计的第一原则。

业务线程上只做 3 件事:

  1. 捕获栈快照(new Throwable().getStackTrace()
  2. 快速定位 Caller(找到第一个业务帧即停)
  3. 非阻塞入队(queue.offer()

其他所有事情都移到异步消费线程:

  • 完整栈帧对象创建
  • 参数转换和格式化
  • 存储写入
  • N+1 检测等诊断分析
  • WebSocket 推送
  • 慢调用标记

为什么这么设计?

业务线程是用户的生命线,多耽误 1ms 都是 1ms 的性能损失。能异步做的,绝不在业务线程上做。

原则 2:队列满直接丢,永不阻塞业务

queue.offer() 是一个非阻塞操作。如果队列满了,offer 返回 false,调用记录直接丢弃。

为什么宁可丢也不阻塞?

因为 dlz-lens 是"锦上添花"的调试工具,不是业务的生命线。如果因为监控工具影响了业务性能,那就是本末倒置了。

丢几条 SQL 日志没关系,业务接口慢了才是大问题。

原则 3:过载保护 — 分级丢弃

不是所有调用都同等重要。当队列使用率超过 80% 时,启动分级丢弃策略:

优先级调用类型说明
🟢 最低快 SELECT< 50ms 的查询,丢了不心疼
🟡 中其他 DMLINSERT/UPDATE/DELETE
🟠 高慢调用超过阈值的调用,有诊断价值
🔴 最高错误 + 事务错误是排障关键,事务是故事骨架

队列越满,丢弃策略越激进。但错误和事务永远优先保留。

原则 4:找到即停 — 栈扫描优化

new Throwable().getStackTrace() 已经是 JVM 里获取栈最快的方式了(native 实现),但它会创建整个栈的 StackTraceElement 数组。

我们的优化是:

  • 只捕获一次栈(不是 calller 一次、完整栈一次)
  • Caller 扫描从栈顶往下找,找到第一个业务帧立即停止
  • 不是创建所有栈帧对象再过滤,是扫描数组时遇到业务帧就返回

对于一个典型的 20-30 层栈,业务帧通常在第 5-10 层,所以只需要扫描几层就能找到。

关键组件详解

CallCollector — 异步采集器

采集器是整个系统的枢纽。所有类型的调用(SQL/Redis/HTTP/MQ)都通过同一个入口 collect() 进入系统。

业务线程
↓ call.collect(record)
├─ 捕获栈快照
├─ 快速定位 Caller
├─ 从 ThreadLocal 取 TraceContext
├─ 绑定 TraceID 和 seq
└─ queue.offer(record) → 非阻塞,失败即弃

消费线程(单线程)

├─ 从队列批量取记录
├─ 完整栈帧对象创建
├─ 参数标准化
├─ N+1 检测(同 trace 同 pattern 计数)
├─ 慢调用标记
├─ 写入 InMemoryStorage
└─ WebSocket 推送到前端

为什么用单线程消费?

  • 避免多线程消费带来的锁竞争和乱序问题
  • 单线程消费的吞吐量已经足够大(每秒几万条轻轻松松)
  • 调用的时序性很重要——单线程保证写入顺序和执行顺序一致

InMemoryStorage — 环形缓冲存储

内存存储,固定容量,循环覆盖。最新的记录在最前面,最老的记录被覆盖。

数据结构:

  • records:调用记录环形数组(默认 1000 条)
  • traces:请求 Trace Map(默认 500 个),按 TraceID 索引
  • traceCallLists:每个 Trace 的调用列表

为什么用环形缓冲?

  • 固定内存占用,不会因为调用量太大而 OOM
  • 写入 O(1),读取 O(n),性能足够
  • 调试场景不需要存历史数据,最新的几百条最有价值

WebTraceFilter — 请求追踪过滤器

Servlet Filter,在每个 HTTP 请求开始时创建 TraceContext 并绑定到 ThreadLocal,请求结束时解绑。

请求进入 → Filter
├─ 生成 TraceID(UUID 短格式)
├─ 创建 TraceContext(包含请求方法、URL、开始时间)
├─ 绑定到 ThreadLocal
└─ 执行 filterChain.doFilter()

整个请求处理过程中,所有 IO 调用自动带上 TraceID

请求返回 → Filter
├─ 记录结束时间、响应状态
├─ 从 ThreadLocal 解绑
└─ 汇总成完整 Story 写入存储

DataSourceProxyBeanPostProcessor — 自动包装

Spring BeanPostProcessor,自动拦截所有 DataSource Bean,用 ProxyDataSource 包装一层。

用户不需要改任何 DataSource 配置,加了依赖就自动生效。

包装逻辑:

  1. Spring 容器初始化时,BeanPostProcessor 拦截所有 DataSource 类型的 Bean
  2. 用 JdkDynamicProxy 或 CGLIB 代理包装原始 DataSource
  3. 代理的 getConnection() 返回 TracingConnection(我们的连接代理)
  4. TracingConnection 的 createStatement() / prepareStatement() 返回 TracingStatement
  5. TracingStatement 包裹 execute/executeQuery/executeUpdate,采集调用信息

层层代理,最终在 Statement 执行层拦截 SQL。这是 JDBC 的最底层,所有基于 JDBC 的框架(MyBatis / JDBCTemplate / JOOQ 等)都能被拦截。

性能数据

业务线程开销分析

操作耗时估算说明
new Throwable().getStackTrace()~100-300nsJVM native 调用,创建 StackTraceElement[]
Caller 快速扫描~50-200ns找到第一个业务帧即停,不遍历全部
ThreadLocal.get()~10ns取 TraceContext
字段赋值~10ns设置 SqlRecord 的各个字段
queue.offer()~20ns非阻塞入队
合计~200-500ns

对比参考:

  • 一次最简单的 MySQL 主键查询:~0.5-1ms
  • 一次 Redis GET:~0.2-1ms
  • P6Spy 的业务线程开销:~1-10μs(微秒级,比我们慢 10-100 倍)

我们的开销不到 SQL 本身耗时的 0.1%。

消费线程吞吐量

单线程消费,保守估计每秒可以处理 5000-10000 条调用记录。

对于一个普通的业务应用,单实例 QPS 几百到一两千,每个请求平均 5-10 次 IO 调用,总调用量每秒几千次——完全在消费能力范围内。

内存占用

按默认配置(1000 条记录 + 500 个 Trace):

数据单条大小数量总大小
调用记录~1-2KB1000~1-2MB
Trace 信息~500B500~250KB
合计~2-3MB

内存占用极小,可以忽略不计。

扩展点

新增调用类型

新增一种调用类型(比如 RocketMQ),只需要:

  1. 新建模块 dlz-lens-mq
  2. 实现对应的代理/拦截器(类似 JDBC 代理)
  3. 调用 CallCollector.collect(record) 上报
  4. 前端扩展类型展示(图标 + 颜色 + 详情面板)

核心框架不需要改。

新增存储后端

当前只有内存存储。未来可以扩展:

  • 本地磁盘存储:写入本地文件,支持历史回溯
  • 远程上报:通过 HTTP/MQ 上报到 SaaS 服务端
  • Redis 存储:用 Redis 做分布式聚合

存储接口 CallStorage 是抽象的,换实现即可。

新增诊断规则

当前内置了慢调用、N+1、事务风险检测。未来可以扩展:

  • 笛卡尔积检测(JOIN 结果集过大)
  • 全表扫描检测(没有 WHERE 条件的 SELECT)
  • 大事务检测(事务内调用数超过阈值)
  • 连接泄露检测(连接持有时间过长)

诊断逻辑在消费线程执行,加新规则不影响业务线程性能。