ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

代码生成器性能优化实战:从40秒到4秒的调优全解析

代码生成器性能优化实战:从40秒到4秒的调优全解析 我接手公司内部那个代码生成器的时候它单次生成300张表的全套CRUD代码要跑40多秒。这个时长说慢不慢但配合上每天几十次的生成操作整个研发团队的耐心基本被磨没了。后来我花了两周时间做优化把耗时压到了4秒以内。今天这篇就是把那两周的调优思路完整拆出来覆盖元数据获取、模板渲染、文件输出、缓存设计、并行改造这几个维度顺带聊聊哪些优化不值得做。如果你正在维护自己的代码生成器或者打算从MyBatis Generator这类工具往自研方向迁移这篇文章应该能帮你少走不少弯路。1. 先把生成器的瓶颈定位清楚三个最容易被忽视的性能黑洞很多人一提到生成器优化第一反应就是“换更快的模板引擎”。但根据我接触过的项目经验模板渲染往往只占总耗时的20%左右真正的瓶颈通常藏在另外三个地方。定位问题永远比盲目优化重要所以我先说一下最常见的三个性能黑洞以及它们的典型特征。1.1 元数据获取反射和数据库连接的重复开销生成器第一步通常要获取表结构信息包括字段名、类型、注释、主键、索引等。这块如果写得随意会非常耗时间。我见过一种反面写法每个字段都通过反射去读类上的注解或者在循环里反复获取数据库连接。反射为什么慢因为每次调用Field.getAnnotation或Method.invoke时JVM都要做访问权限检查、方法查找还会触发类型擦除相关的开销。300张表、每张表平均20个字段那就是6000次反射调用累积下来就是个不小的数字。数据库连接的开销更夸张。如果生成200张表每张表都用DriverManager.getConnection新建连接光连接握手就要吃掉好几秒。即使使用连接池反复创建PreparedStatement和ResultSet也是开销。这里有个简单的定位方法在生成器入口和各个关键节点打时间戳用System.currentTimeMillis()或者StopWatch记录每段耗时。我当年定位时发现元数据获取占了总耗时的55%这才是首先要解决的对象。1.2 模板渲染循环内的对象创建和字符串拼接FreeMarker和Velocity这类模板引擎本身性能不算差但使用姿势不对就会很糟糕。最常见的问题是每次生成文件前都重新加载模板文件并创建一个新的Template对象。每个Template对象的创建涉及模板文件读取、词法分析、语法树构建。把这三步放到循环里做每生成一个文件就重复一次等于把解析成本线性放大。我见过一条完整生成流程跑500个文件、每个文件都重新加载模板的案例仅模板加载就占了总耗时的30%。另一种典型问题是在模板里做大量字符串拼接尤其是用${...}嵌套表达式拼长字符串。每解析一个表达式就是一次求值嵌套越深开销越大。有些人在模板里写了几十行复杂的字符串处理逻辑这已经脱离模板引擎的设计本意了。1.3 文件输出没有缓冲的逐行写入IO这块看着不起眼实际上是压死骆驼的最后一根稻草。默认的FileWriter没有缓冲区每次write都是一次系统调用。生成一个300行的Java文件就是300次写入操作。500个文件全部用这种方式写后果可想而知。同样没被注意到的还有目录创建。有些生成器每写一个文件就调用一次mkdirs即使目录已经存在。mkdirs本身要做文件系统检查几百次调用下来也不是小开销。我把这三个瓶颈整理成一个简单的表格方便你对照自己的生成器排查瓶颈典型开销优化方向元数据获取反射调用、数据库连接重复创建缓存表结构信息复用连接模板渲染每次重新加载模板、复杂表达式模板预编译、计算前置到数据模型文件输出无缓冲逐行写入、频繁mkdir缓冲输出、批量写入、增量生成2. 模板层的核心优化预编译、模型预处理、动静分离说完了定位接下来是模板层怎么动的具体做法。这一层优化直接影响生成效率和模板维护成本值得细抠。2.1 模板预编译一次加载反复使用FreeMarker的Configuration对象本质上是线程安全的Template对象也支持并发复用。正确做法是在生成器初始化阶段一次性加载所有模板后续生成过程全程复用而不是每次进入循环时new Template。Configuration cfg new Configuration(Configuration.VERSION_2_3_32); cfg.setDirectoryForTemplateLoading(new File(templates)); cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); cfg.setLogTemplateExceptions(false); // 初始化阶段统一加载 MapString, Template templateCache new HashMap(); templateCache.put(entity.ftl, cfg.getTemplate(entity.ftl)); templateCache.put(mapper.ftl, cfg.getTemplate(mapper.ftl)); templateCache.put(service.ftl, cfg.getTemplate(service.ftl));这里要注意一点FreeMarker的Configuration在setDirectoryForTemplateLoading之后会缓存模板解析结果但前提是你不要每次调用getTemplate时传入不同的Locale或者自定义的TemplateLoader。保持模板加载参数一致才能让缓存生效。2.2 把逻辑从模板里搬出来数据模型层算好再传模板引擎擅长的是遍历和插值不是业务计算。很多人在模板里写#list columns as col #if col.columnType varchar ${col.javaType} ${col.javaField} /#if /#list这种条件判断一多模板就变得难以阅读解析效率也直线下降。更好的做法是在Java代码里把数据模型预处理成渲染时需要的形状。比如生成Mapper XML时提前算好哪些字段需要出现在insert列表、哪些出现在update列表、哪些作为查询条件然后把这些计算好的ListString直接放入数据模型MapString, Object dataModel new HashMap(); dataModel.put(tableName, tableInfo.getTableName()); dataModel.put(insertColumns, tableInfo.buildInsertColumns()); dataModel.put(updateColumns, tableInfo.buildUpdateColumns()); dataModel.put(queryConditions, tableInfo.buildQueryConditions());模板只负责遍历这些现成的列表不在模板里做二次判断。这样做的好处有两个第一模板变短了解析和渲染都更快第二生成逻辑更容易单元测试因为核心计算都转移到了普通Java方法中可以直接写测试用例验证。2.3 静态片段与动态片段分离能不动就别动每个生成文件里都有一堆固定内容文件头注释、版权声明、import列表、类注释。这些内容每次都从模板解析一遍纯属浪费。简单高效的方案是把静态片段单独存储生成时直接用字符串拼接只有动态变化的部分才走模板渲染。我还遇到过一个更妙的技巧对于完全静态的文件比如项目通用的pom.xml、.gitignore、README.md可以直接在生成器里做文件复制压根不走模板引擎。一个Files.copy就能完成的工作没必要让模板引擎参与。3. 缓存策略实施指南表结构缓存、配置缓存与渲染结果缓存模板层优化做完之后下一个大头就是缓存。缓存策略用好了可以让生成器在第二次跑的时候快到几乎不用等待。3.1 表结构信息缓存同一个生成任务内部先查一次重复使用一个生成任务通常涉及多张表而同一张表的信息会被用于生成Entity、Mapper、Service、Controller等多个文件。如果每个文件都重新连数据库查一次表结构效率极其低下。我在优化时给表结构加了一个TableSchemaCache粒度是“表名 - 完整结构信息”同一个生成任务内只查询一次public class TableSchemaCache { private static final MapString, TableInfo CACHE new ConcurrentHashMap(); public static TableInfo getTableInfo(String tableName, DataSource dataSource) { return CACHE.computeIfAbsent(tableName, key - { // 实际的元数据查询逻辑 return MetadataLoader.loadTableInfo(key, dataSource); }); } }注意这里用的是ConcurrentHashMap因为后面启用并行生成后多个线程会同时请求同一张表的元数据。computeIfAbsent能保证同一时刻只有一个线程真正执行查询其他线程等待结果避免重复查询。跨生成任务的缓存就要谨慎一点因为表结构可能被开发人员手动修改过。如果要做跨任务缓存建议加一个“结构签名”比如把所有字段的类型、长度、注释拼成一个字符串算出哈希值。哈希值变了就说明表结构变了需要重新拉取。3.2 配置文件解析缓存别在循环里反复读配置这个问题特别隐蔽。生成器的配置通常是一个YAML或XML文件里面定义了输出路径、包名、模板映射等。很多人的代码是每次生成文件前都重新加载一遍配置然后解析出路径。实际上配置在整个生成过程中是不会变的。正确的做法是在生成器启动时加载一份配置对象全局共享。用Properties、YAML解析器或者Jackson都行关键是一次解析、内存复用。GeneratorConfig config GeneratorConfigLoader.load(generator-config.yaml); // 全局只load一次这个优化虽然不起眼但能让每条生成链路省掉一次文件读取和一次YAML解析。几百个文件累积下来节省的时间相当可观。3.3 渲染结果缓存到底该不该缓存生成的代码这一条需要分开看。如果你的生成器每次生成都带有动态时间戳那渲染结果缓存没什么意义因为结果永远在变化。但如果你生成的代码是确定性的输出来源相同那缓存就有价值。我的经验是缓存渲染结果不如缓存“是否要重新生成”的判断。这正是增量生成的思路下一章会详细展开。判断逻辑简单说就是如果目标文件已经存在且内容与本次生成结果完全一致就跳过写入。这个判断本身需要先渲染出结果但它能避免不必要的磁盘写入从而让生成过程只在必要时才触发IO。所以我的建议是渲染结果缓存可以不做但“对比后决定是否写文件”一定要做。两者效果相近后者对内存占用更友好。4. 从串行到并行批量生成怎么改才安全串行生成是大部分生成器的默认状态先取表A的元数据渲染表A的文件再取表B的元数据渲染表B的文件。这种模式逻辑清晰但明显浪费了现代CPU多核的能力。4.1 拆成三个阶段元数据加载、渲染、落盘并行化改造的第一步不是加线程池而是重新组织任务结构。我推荐把整个生成流水线拆成三个阶段阶段一批量加载所有需要的表结构信息串行即可因为数据库连接资源和元数据解析本身不适合高并发阶段二并发渲染所有文件该阶段CPU密集适合多线程阶段三统一落盘如果开启了增量生成该阶段做对比和写入这样的拆分保证了数据准备阶段不浪费线程资源渲染阶段能充分利用多核落盘阶段又不会产生并发写入冲突。4.2 线程池参数与线程安全边界渲染阶段的并行度设置我一般推荐用CPU核心数-1不是越大越好。因为渲染是CPU密集型操作线程数超过核心数反而会增加上下文切换开销。int threads Math.max(2, Runtime.getRuntime().availableProcessors() - 1); ExecutorService executor Executors.newFixedThreadPool(threads); ListFuture? futures new ArrayList(); for (TableInfo tableInfo : allTables) { futures.add(executor.submit(() - renderOneTable(tableInfo))); } for (Future? future : futures) { future.get(); // 等待所有任务完成 }线程安全是并行改造中最容易踩坑的地方我整理了三个必须注意的点FreeMarker的Template对象是线程安全的但Template渲染时如果依赖共享的Configuration就不要同时修改Configuration对象输出路径的拼接逻辑如果依赖当前线程的临时状态要保证每个线程拿到的是独立的数据模型副本如果多个线程生成同一个包下的同名文件一定要预判文件是否冲突。我的做法是先把所有输出路径算出来放到集合里查重有重复直接报错不等到写文件时才发现4.3 并行不是银弹IO密集型和CPU密集型的取舍如果生成器的瓶颈在数据库查询和文件写入并行的效果会非常有限。数据库连接池就那么几个连接文件写入又受磁盘性能限制开100个线程也没用。所以我建议先做好前两章的优化让生成器变成以CPU渲染为主的模式再上并行收益才最大。我实测的数据是优化前串行40秒其中元数据获取22秒、渲染9秒、文件落地9秒。前两章优化做完后整体降到12秒左右。再把渲染阶段并行化最终稳定在4秒上下。这说明每一步优化都在为下一步打基础跳步反而容易失败。5. 文件输出侧的精细优化缓冲、编码与增量写入文件落在磁盘上的过程看起来简单但细节非常多。踩过几次坑之后我把这块的优化经验整理成了几条硬规则。5.1 统一使用缓冲写入所有文件写入操作必须使用BufferedWriter或者Files.write禁止裸露的FileWriter。原因前面已经提过无缓冲的FileWriter每次write都是系统调用。Files.write(Paths.get(targetPath), content.getBytes(StandardCharsets.UTF_8));Files.write内部会按文件大小自动分配缓冲区比手工包一层BufferedWriter更简洁。对于大文件可以用BufferedWriter手动指定缓冲区大小一般8KB或16KB就够了。5.2 换行符和编码要固定否则每次生成都会“假装”改了文件这个问题非常容易忽视。Windows环境下默认换行符是CRLFLinux是LF。如果生成器在Windows上开发、代码提交到Linux仓库每次生成文件都会把行尾从LF变成CRLF或者反之Git记录里每个文件都变成了“已修改”但实际上内容根本没变。这个过程会浪费大量时间还会干扰增量生成的正确性。我的解决方案是统一使用LF换行符。在Java里可以用系统属性强制指定或者更简单地在生成内容写入前做一次规范化的换行符替换content content.replace(\r\n, \n);编码统一使用UTF-8这一点所有生成器都应该做到凡是出现中文乱码的生成器八成是使用了平台默认编码。5.3 增量生成用内容对比代替盲目覆盖增量生成是文件输出侧最重要的优化手段它解决的不只是性能问题还解决了版本控制的困扰。实现思路非常简单渲染出本次生成的字符串内容判断目标文件是否存在如果存在读取旧文件内容与新内容做对比相同则跳过写入不同则写入新内容Path target Paths.get(targetPath); byte[] newContent content.getBytes(StandardCharsets.UTF_8); if (Files.exists(target)) { byte[] oldContent Files.readAllBytes(target); if (Arrays.equals(oldContent, newContent)) { return; // 内容一致跳过写入 } } Files.createDirectories(target.getParent()); Files.write(target, newContent);这里有个工程细节需要提醒如果每次生成都在文件头加了动态时间戳增量生成就废了因为时间戳永远不同每次都会覆盖写文件。我的解决方法是只在代码注释里保留“生成时间”字段但用固定格式的占位符区分真正的变更或者干脆在文件头写“生成时间”时用无意义的固定值比如“生成日期: GENERATED_AT”代替真实时间。5.4 批量写入的极端场景大量小文件的处理如果一次生成几千个小文件比如DTO和VO拆得很细的项目可以考虑先全部渲染到内存中的Map最后统一循环写入。这样做的好处是避免“生成一个写一个”造成的反复切换上下文也方便在写入前做一个全局的文件冲突检测。极端情况下可以用ZipOutputStream打包生成结果让用户下载一个zip包而不是几百个散文件。这个方案在Web版生成器上特别实用对磁盘IO的压力也小很多。6. 生成策略本身的优化模板收敛与按需裁剪性能优化做到这一步生成器已经很快了但还缺最后一块拼图生成策略的优化。这一层解决的问题不是“生成更快”而是“生成更对”。速度再快生成一堆没人要的代码也是白费。6.1 模板数量的收敛用配置驱动替代到处复制模板很多团队生成器做大了之后模板文件开始膨胀。CRUD一套模板、分页查询一套模板、树形结构一套模板、乐观锁一套模板……每个模板之间往往只有细微差别。我的建议是减少模板数量增加配置项。把表的主键类型、是否逻辑删除、是否包含乐观锁版本号等信息放进配置或表元数据中在渲染时通过条件控制输出而不是每个场景复制一套新模板。模板数量少了维护成本直线下降。正常业务生成器保持在5个以内的核心模板Entity、Mapper接口、Mapper XML、Service、Controller是比较健康的。超过10个模板就该考虑是不是有人在重复造轮子了。6.2 按需生成不是所有表都要完整的全套代码另一个策略层面的优化是生成范围的控制。不是每张表都需要生成全套代码。比如字典表可能只需要Entity和Mapper不需要Service中间关联表可能只需要Mapper XML不需要Controller。生成器应该支持按表指定生成范围或者按命名规则自动推断。比如表名以rel_开头的只生成Mapper层以log_开头的只生成Entity。这个优化虽然不影响生成速度但对使用体验的提升非常明显。团队成员不再需要每次生成完再手动删掉一堆不需要的代码。6.3 优化策略的执行顺序与验证指标最后给一个明确的执行顺序这是我在多个项目上验证过的比较合理的优化路径先加日志打点统计各阶段耗时确定瓶颈做模板预编译和配置缓存改动小收益明显做表结构信息缓存元数据获取通常是最大瓶颈统一缓冲写入、固定编码和换行符实现增量生成对比内容跳过相同文件最后才是并行渲染用多核加速纯CPU部分每做完一步都建议用同一批表跑一遍耗时数据记录在表格里保证优化方向是可验证的而不是靠感觉。注意优化过程中尽量保持生成结果的可读性。最典型的反面案例是为了减少输出内容把原本清晰的代码缩成了单行或者用缩写变量名结果省了那几毫秒用户却看不懂生成的代码了。代码生成器的核心价值始终是“快速产出可读、可维护的代码”速度只是一部分。按我这些年维护生成器的经验最值得做的其实是元数据缓存和增量生成这两件事它们解决了80%的体验问题。剩下的并行化、模板收敛这些优化更多是在存量方案上继续打磨。如果你只挑一件事做先做增量生成它能从源头上避免“生成一遍目录就乱一遍”的问题也会让整个工具在Git工作区内显得干净利索。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进