架构设计
了解 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-sql | JDBC 代理(DataSource/Connection/Statement) | core |
| dlz-lens-mybatis | MyBatis 拦截器(Mapper定位增强) | core, mybatis |
| dlz-lens-redis | Redis 命令采集(Lettuce/Jedis 代理) | core |
| dlz-lens-http | HTTP 调用采集(RestTemplate/WebClient/Feign) | core |
| dlz-lens-spring | Spring 集成(WebTraceFilter、DataSource自动包装) | core, jdbc, spring-web |
| dlz-lens-console | Web 控制台(REST API + WebSocket + 前端 + AutoConfig) | core, spring |
依赖关系
console → spring → sql → core
↘ mybatis ↗
↘ redis
↘ http
用户只需要引入 dlz-lens-console,其他模块通过传递依赖自动引入。
核心设计原则
原则 1:业务线程只做最必要的事
这是性能设计的第一原则。
业务线程上只做 3 件事:
- 捕获栈快照(
new Throwable().getStackTrace()) - 快速定位 Caller(找到第一个业务帧即停)
- 非阻塞入队(
queue.offer())
其他所有事情都移到异步消费线程:
- 完整栈帧对象创建
- 参数转换和格式化
- 存储写入
- N+1 检测等诊断分析
- WebSocket 推送
- 慢调用标记
为什么这么设计?
业务线程是用户的生命线,多耽误 1ms 都是 1ms 的性能损失。能异步做的,绝不在业务线程上做。
原则 2:队列满直接丢,永不阻塞业务
queue.offer() 是一个非阻塞操作。如果队列满了,offer 返回 false,调用记录直接丢弃。
为什么宁可丢也不阻塞?
因为 dlz-lens 是"锦上添花"的调试工具,不是业务的生命线。如果因为监控工具影响了业务性能,那就是本末倒置了。
丢几条 SQL 日志没关系,业务接口慢了才是大问题。
原则 3:过载保护 — 分级丢弃
不是所有调用都同等重要。当队列使用率超过 80% 时,启动分级丢弃策略:
| 优先级 | 调用类型 | 说明 |
|---|---|---|
| 🟢 最低 | 快 SELECT | < 50ms 的查询,丢了不心疼 |
| 🟡 中 | 其他 DML | INSERT/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 配置,加了依赖就自动生效。
包装逻辑:
- Spring 容器初始化时,BeanPostProcessor 拦截所有 DataSource 类型的 Bean
- 用 JdkDynamicProxy 或 CGLIB 代理包装原始 DataSource
- 代理的
getConnection()返回 TracingConnection(我们的连接代理) - TracingConnection 的
createStatement()/prepareStatement()返回 TracingStatement - TracingStatement 包裹 execute/executeQuery/executeUpdate,采集调用信息
层层代理,最终在 Statement 执行层拦截 SQL。这是 JDBC 的最底层,所有基于 JDBC 的框架(MyBatis / JDBCTemplate / JOOQ 等)都能被拦截。
性能数据
业务线程开销分析
| 操作 | 耗时估算 | 说明 |
|---|---|---|
new Throwable().getStackTrace() | ~100-300ns | JVM 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-2KB | 1000 | ~1-2MB |
| Trace 信息 | ~500B | 500 | ~250KB |
| 合计 | ~2-3MB |
内存占用极小,可以忽略不计。
扩展点
新增调用类型
新增一种调用类型(比如 RocketMQ),只需要:
- 新建模块
dlz-lens-mq - 实现对应的代理/拦截器(类似 JDBC 代理)
- 调用
CallCollector.collect(record)上报 - 前端扩展类型展示(图标 + 颜色 + 详情面板)
核心框架不需要改。
新增存储后端
当前只有内存存储。未来可以扩展:
- 本地磁盘存储:写入本地文件,支持历史回溯
- 远程上报:通过 HTTP/MQ 上报到 SaaS 服务端
- Redis 存储:用 Redis 做分布式聚合
存储接口 CallStorage 是抽象的,换实现即可。
新增诊断规则
当前内置了慢调用、N+1、事务风险检测。未来可以扩展:
- 笛卡尔积检测(JOIN 结果集过大)
- 全表扫描检测(没有 WHERE 条件的 SELECT)
- 大事务检测(事务内调用数超过阈值)
- 连接泄露检测(连接持有时间过长)
诊断逻辑在消费线程执行,加新规则不影响业务线程性能。