把Java项目ApiGo使用Go语言重构为ApiGoo,提升了哪些
背景: ApiGo 是一个gitee上开源的 SQL2API 平台——在线写 SQL,AI对话即可快速发布成 REST 接口。Java版是 Spring Boot 4 + Mybatis,实现MySQL/Oracle/SQLServer/达梦/ClickHouse 等十几种数据库的 JDBC 驱动全打进一个 fat jar。ApiGoo 现在用 Go(Gin + database/sql) 把它完整重构了一遍功能 。

一、先回答最关心的问题:性能提升了多少
直接给结论,后面逐条展开:
| 维度 | Java 版 | Go 版 | 变化 |
|---|---|---|---|
| 交付物 | fat jar,含 11 个 JDBC 驱动 + Spring 全家桶,百 MB 级 | 静态编译单二进制,实测 58MB(-s -w,含全部驱动和内嵌前端) | 体积减半以上 |
| 运行时内存 | JVM 起步堆 + Metaspace + JIT 代码缓存,这类应用通常几百 MB | 无 JIT、无 Metaspace,预期几十 MB | 预期 3~5 倍 |
| 启动时间 | 十秒级(依赖栈很重) | 毫秒到秒级 | 一个数量级 |
| 热态单请求延迟 | 基线 | 预期小幅改善(10%~30% 量级) | 不是数量级的差距 |
| 热态吞吐上限 | 基线 | 同上 | 取决于目标数据库,不取决于语言 |
为什么单请求延迟不会有数量级提升?因为这不是一个 CPU 密集型服务。这是一个 SQL 网关,一次数据面请求的时间大头永远在目标数据库执行 SQL 和网络往返上。应用侧能优化的只有:鉴权、元数据查找、动态 SQL 渲染、取连接、行扫描、JSON 序列化这几段,而这几段在两个版本里都做了缓存,热路径都很薄。语言层面的差异被 I/O 稀释了。
真正拉开差距的是内存、启动和部署密度,这三样恰恰是"重构到 Go"最容易被低估的价值。
二、两个版本的数据面热路径,逐层对账
把一次 /api/web/ 请求在两边走过的路摆在一起看,比空谈"Go 更快"有用得多。
Java 版:
Tomcat(虚拟线程)
→ ApiFilter(响应体包装)
→ AuthInterceptor(负缓存 → Caffeine apiCache → 限流 Bucket4j → 鉴权,ApiThreadLocal 存请求快照)
→ ApiServiceController
→ BaseDataServiceImpl(语句拆分、类型分发)
→ MyBatis(CachedXmlLanguageDriver → SqlSourceCache,LinkedHashMap 结果)
→ JdbcDataSourceRouter(ThreadLocal 路由 + JDK 动态代理包 Connection 还原 schema)
→ HikariCP → 目标数据库
→ Jackson 序列化返回
Go 版:
gin(每请求一 goroutine)
→ BodyLimit / DataAccessLog(响应体捕获有上限)
→ DataAuth(singleflight 缓存 → 令牌桶限流 → 鉴权)
→ DataController → Executor
→ dynamic.Render(脚本节点树 LRU 缓存,单遍 #{} → ?)
→ manager.RouteConn(显式传参路由,会话状态查询 + 切换语句)
→ database/sql 连接池 → 目标数据库
→ scanRows → OrderedMap
→ encoding/json 序列化返回
逐层看差异在哪、不在哪:
1. 请求解析与路由。 Java 走 Spring MVC 的 handler mapping + 反射参数绑定;Go 是 gin 的前缀树匹配,数据面还是一条通配路由。这段 Go 确实省了反射开销,但量级是微秒级,对整体延迟贡献很小。
2. 动态 SQL 渲染。 这是最值得一提的对比。Java 版代码里 SqlSourceCache.java 的注释自己承认:MyBatis 的 ProviderSqlSource#getBoundSql 每次调用都会重新走 XMLScriptBuilder 解析 <if>/<foreach> 并构建 OGNL 表达式树,"是动态 SQL 场景下每请求最大的 CPU 开销"。Go 版重写时自建了整个动态 SQL 引擎(internal/dynamic,if/foreach/choose/trim 全套语义对齐 MyBatis),但设计上直接吸取了这个教训:脚本解析成节点树、表达式解析成 AST,各挂一个 LRU 缓存,命中路径只做树的遍历和 #{} 到 ? 的单遍改写。也就是说,Java 版历史上最痛的 CPU 热点,在 Go 版是架构层面就不存在的。 。
3. 连接路由——Go 版少了两层"魔法"。 Java 版切 schema 用 ThreadLocal 存路由 key,用 JDK 动态代理把 Connection 包一层在 close 时还原 catalog。虚拟线程环境下大量 ThreadLocal 读写不算便宜,动态代理每次方法调用都过反射。Go 版是显式传参:RouteConn(ctx, route) 拿到连接后直接发会话切换语句,归还前执行还原语句,还原失败就把物理连接丢弃。没有代理、没有 ThreadLocal、没有泄漏风险。这段每请求省的开销是真实存在的,但同样是大延迟里的小头。
4. JSON 序列化——一个反直觉的取舍。 很多人以为 Go 版会换 sonic 或 goccy 这种高性能序列化器,事实上 go.mod 里它们只是间接依赖,数据面用的是标准库 encoding/json。原因写在注释里:要对齐 Jackson 的输出语义(NON_NULL、不转义 HTML、字段按插入顺序),做到与 Java 版响应体逐字节一致。所以序列化这一层,Go 版没有性能优势,是刻意放弃的。这提醒一件事:重写的目标如果是兼容性,有些"更快的选择"就得让位。
5. 行扫描。 两边都是全量物化(上限 10000 行,超限报错不截断)。Go 版做了点细活:每行的扫描缓冲跨行复用、OrderedMap 手写 MarshalJSON 预分配 buffer、数字用 json.Number 保精度。这种优化单看都不大,堆在热路径上就是可观测的 GC 压力差异。
三、真正的大头:内存、启动、部署密度
内存。 JVM 这边的固定开销是刚性的:堆下限、Metaspace(Spring Boot 4.1 + MyBatis + 一堆 starter 的类体积)、JIT 编译后的代码缓存。这类网关型应用堆本身不大,但 JVM 底噪摆在那里,容器里通常要给到 512MB~1GB 才踏实。Go 版没有这些概念,内存就是堆 + goroutine 栈,几十 MB 是常态。同样一台 8G 的机器,能跑的实例数差 3~5 倍。对多租户或者大量小实例部署的场景,这是比 QPS 实在得多的收益。
启动。 Java 版带着 11 个 JDBC 驱动和 Spring AI 的依赖栈,启动是十秒级;Go 版进程起来到能服务是亚秒级。这不只是体验问题:弹性伸缩的扩容速度、发布回滚的速度、K8s 里 readiness 探针的容忍窗口、CI 里集成测试的耗时,全都受益。冷启动还有一层:JIT 需要预热,刚起来的 Java 实例前几千个请求的延迟明显高于热态;Go 是 AOT 编译,没有这个爬坡期。
交付物。 Java 版 Dockerfile 基于 eclipse-temurin:21-jdk-alpine,镜像几百 MB;Go 版实测 58MB 静态二进制塞进 alpine:3.22,还能以 --read-only --tmpfs /tmp 跑只读容器。攻击面、分发、升级都更干净。
GC 要说句公道话。 "Go 的 GC 比 JVM 好"是个流传很广的误解。论吞吐,Go 的并发标记清理在高分配率下花的 CPU 其实比成熟 JVM 的分代收集器更多;论停顿,JDK 21 的 ZGC 也在亚毫秒级。Go 在 GC 上的真实优势是没有分代假设带来的内存放大,footprint 小,以及不需要预热。把这个当成"Go GC 全面碾压"来宣传,是不准确的。
四、并发模型:虚拟线程已经替 Java 抢回一局
得承认一个事实:Java 版开了 spring.threads.virtual.enabled: true,Tomcat 请求线程是虚拟线程。这意味着传统 Java 网关最致命的问题——线程数撑不住万级并发长连接——在这个版本里已经不存在了。
所以 Go 的 goroutine 在这件事上的优势,从"质变"降级成了"简化":
- 没有 pinning 风险,不用操心 synchronized 块把虚拟线程钉在 carrier 线程上;
- 没有 ThreadLocal 的语义陷阱(虚拟线程海量创建下,ThreadLocal 的清理和继承都更贵,而 Java 版的路由恰恰依赖 ThreadLocal);
- 调度行为可预期,不依赖 JVM 版本和参数。
这是工程简洁性上的优势,不再是一骑绝尘的性能优势。如果是重构一个还在用传统线程池 + 同步阻塞 I/O 的 Java 服务,收益会大得多;重构一个已经上了虚拟线程的,就要调低预期。
Go 版在并发上做对的事更多是纪律性的:整个路由层不私自起协程,后台任务(日志消费、数据源保活、定时刷新)总共三类,全部包了 panic 恢复;访问日志是 2000 容量的有界队列,入队一次 channel 尝试即返回,满了丢弃计数,批量落库拼单条多 VALUES 的 INSERT——写路径零阻塞,队满不拖垮请求线程。这些 Java 版也有对应设计(LinkedBlockingQueue + 单线程消费),两边打平。
五、代价
代码量翻倍。 Java 版约 1.5~2 万行;Go 版约 3 万行(含 6500+ 行测试)。多出来的部分是什么?是自己手写的"MyBatis 生态等价物":动态 SQL 引擎(internal/dynamic 约 115KB)、方言和占位符改写、分页包装、LRU 缓存、令牌桶、连接池的保活/状态还原/回收外壳。Java 里一个依赖解决的事,Go 里全是自己的代码,每一行都要自己养。MyBatis 二十年沉淀的边角案例(各种方言的转义、奇怪的类型映射),Go 版要靠测试和线上反馈一个个补。
测试倒是翻盘了。 Java 版核心数据面(AuthInterceptor、JdbcStatement、SQLUtil)几乎没有单测,测试集中在 MCP 工具;Go 版 154 个文件里 45 个是 _test.go,方言改写、interpolate 开关、连接丢弃语义、LRU、singleflight 都有专门用例。重写给了补齐测试的机会,而且 Go 的表驱动测试写起来确实顺手。
生态断档。 Druid 的 SQL 监控面板、PageHelper 的多方言分页成熟度、Spring AI 的周边,这些在 Go 侧要么自己造了简版,要么没有。JDBC 驱动三十年积累 vs Go 各数据库驱动质量参差(达梦、DolphinDB 这类国产库尤其明显),是长期要填的坑。
六、什么样的Java项目值得
结合这次经历,我的判断标准:
适合重写的信号:
- 部署密度是痛点:大量小实例、边缘节点、内存受限环境;
- 启动速度有业务含义:弹性伸缩、Serverless、频繁发布;
- 应用本质是 I/O 网关/胶水层,业务逻辑薄——没有复杂领域模型要迁移;
- 团队本来就想引入 Go,需要一个有真实业务压力的项目练手;
- 旧代码的技术债重到"读一遍就想重写"——重写过程修掉的隐性 bug 可能比重写本身值钱。
不适合的信号:
- 重度依赖 Java 生态:复杂 ORM 映射、事务管理、消息、规则引擎、Spring AI 周边;
- 团队全是 Java 背景,没有持续投入 Go 的打算;
- 指望的是"QPS 翻 N 倍"——尤其是目标服务已经上了虚拟线程的话,I/O 密集场景里这个期望大概率落空;
- 没有办法保证一段时间内双版本并行验证(响应体逐字节对齐这种验收标准,是这次敢切换的底气)。
go 版本开源地址: ApiGoo
Java版开源地址: ApiGo