news 2026/11/2 20:56:52

Doma常见问题排查:从构建期到运行期的SQL映射与事务实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Doma常见问题排查:从构建期到运行期的SQL映射与事务实践

Doma 这个 Java 数据访问框架在国内技术圈的存在感一直不算高,但在一些对 SQL 可控性要求极高的老系统里,它出现的频率远比想象中高。我头一回接手这类代码库时的真实感受是:DAO 接口干净得吓人,只有一个方法签名和一行注解,真正的 SQL 全躺在META-INF目录下的.sql文件里,改一个字段要同时动实体、SQL、还有可能动配置类。刚上手那两周,我踩的坑基本覆盖了这套 Doma 框架常见问题解决方案里最典型的几类——编译期注解处理器没生效、SQL 文件找不到、2-way SQL 注释写错、实体映射对不上、事务边界不清。后来把这些故障按"发生阶段"重新梳理了一遍,排查效率才明显提上来。这篇内容就是按那个思路组织的:从构建期到运行期,把每一层最常炸的地方、为什么炸、怎么快速定位讲清楚。适合正在用这套框架做业务开发的同学,也适合刚接手别人留下的 Doma 项目、被一堆[DOMAxxxx]消息码搞得一头雾水的同学。里面涉及的操作步骤和配置片段都是可以直接抄去改的,参数选择也给了判断依据,不用再靠猜。

1. 先把问题分层:Doma 的故障面到底在哪

1.1 为什么这套框架的坑集中在"构建期"和"资源定位"

Doma 的核心设计取向是"SQL 优先 + 编译期校验"。它不像全自动 ORM 那样在运行时才去推断字段和列的对应关系,而是在 javac 跑注解处理器(annotation processor)的阶段,就把 DAO 接口、实体类、SQL 文件这三者拉到一起做一致性检查,然后生成XxxDaoImpl这样的实现类。这个设计带来的好处很直接:字段拼错、SQL 里引用了不存在的参数、实体属性与查询列对不上,这些问题在mvn compile阶段就会报出来,不会拖到测试环境才冒烟。

但代价也很明显——一旦注解处理器没有正确挂到编译流程上,整个链路就断了。这时候你看到的现象往往不是"编译报错说处理器缺失",而是"编译通过了,但运行时提示找不到XxxDaoImpl",或者 IDE 里接口上全是红色波浪线而命令行编译却正常。这类"看起来像运行时问题、实际是构建配置问题"的故障,占了我遇到的全部问题的三成以上。

另一半问题集中在资源定位上。SQL 不在 Java 代码里,而在 classpath 的特定目录下,靠"包名 + 接口名 + 方法名"这套约定去查找。只要打包方式、资源过滤、编码格式、甚至换行符有一处不对,运行时就是一句冷冰冰的"SQL file not found"。所以排查这类框架的问题,第一件事永远不是去看 SQL 写得对不对,而是先确认两件事:注解处理器跑了没有、SQL 文件到底在不在 classpath 里。

1.2 常见问题四象限与推荐的排查顺序

我把这些年遇到的问题按"发生阶段"和"根因类型"切成四块,实际排查时按顺序往下走,基本不会绕远路。

阶段典型现象根因方向优先动作
构建期找不到XxxDaoImpl、IDE 报红但命令行正常注解处理器未配置、版本不匹配、增量编译未触发检查编译插件的 processor 配置
构建期编译报"参数未找到"、绑定变量校验失败缺-parameters编译选项、参数名被擦除打开编译参数保留
启动/运行期SQL file not found、读到的 SQL 是乱码资源路径不符合约定、资源过滤破坏文件、编码非 UTF-8解压 jar 看实际路径与内容
运行期映射为 null、类型转换异常、唯一约束异常、乐观锁失败命名策略、Domain 转换器、事务边界、并发冲突打开 SQL 日志看真实执行语句

这个表的用法是"对号入座":先看现象落在哪一行,再按最后一列的动作去验证。不要跳过验证直接改代码,我见过太多次"以为是 SQL 写错了,改了半天,最后发现是资源文件根本没打进 jar"的情况。

还有一个容易被忽略的点:Doma 的错误信息大多带一个[DOMA开头的消息码。这个码是官方文档里可查的,同一个码对应同一类语义。养成"先看码、再看堆栈"的习惯,比从头读异常信息快得多。比如同样是抛异常,NoResultException表示"期望恰好一条却查到了零条",NonUniqueResultException表示"期望一条却查到了多条",这两个的修复方向完全相反,看清码值能省掉一半猜测。

2. 注解处理器没跑起来:最隐蔽的第一道坎

2.1 报错长什么样,怎么三分钟内确认是处理器没生效

判断方法很朴素:去target/classes(Maven)或build/classes(Gradle)下面找有没有XxxDaoImpl.class。有,说明处理器正常工作;没有,那就别往下查了,问题百分百在构建配置上。这个方法比看任何报错都直接,因为处理器失效时编译器通常不会给你明确提示,它只是"安静地什么都没做"。

运行时表现则取决于你的装配方式。手工new的场景下,你会看到ClassNotFoundException: com.example.dao.PersonDaoImpl;用依赖注入容器的场景下,报错会变成"找不到符合条件的 bean",然后一路往上抛,最后那句异常信息跟 Doma 已经没关系了,很容易把人带到依赖注入配置上去排查,白白浪费时间。

还有一种更阴的情况:命令行mvn clean package一切正常,但 IDE 里点运行就挂。原因是 IDE 用的是自己的增量编译器,注解处理器的注册信息没有同步过去。这时候别怀疑代码,去 IDE 的注解处理设置里把处理器显式加上,或者在项目根目录先跑一次完整的命令行构建,让生成的源码落到target/generated-sources下,IDE 一般会自动把它识别成源码目录。

提示:任何时候怀疑处理器没生效,先clean,再完整构建一次,再去看生成目录。增量编译的缓存会让"改了配置却没生效"这种情况反复出现。

2.2 Maven 与 Gradle 的配置写法与版本对齐原则

Maven 侧的关键是把处理器放进annotationProcessorPaths,而不是简单地放在dependencies里。后者在老版本 JDK 上能工作,但在 JDK 9 之后的模块化编译流程里,处理器必须走独立路径,否则会出现"处理器在 classpath 上但编译器拒绝加载"的情况。

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <source>17</source> <target>17</target> <!-- 关键点一:保留方法参数名,Doma 靠参数名做绑定 --> <parameters>true</parameters> <annotationProcessorPaths> <path> <groupId>org.seasar.doma</groupId> <artifactId>doma-processor</artifactId> <version>${doma.version}</version> </path> </annotationProcessorPaths> <!-- 关键点二:显式声明处理器,避免被其他处理器"抢跑" --> <compilerArgs> <arg>-Adoma.resources.dir=${project.basedir}/src/main/resources</arg> </compilerArgs> </configuration> </plugin>

Gradle 侧稍微简洁一些,但有一个坑必须提前说:Kotlin 项目里annotationProcessor配置对 Kotlin 源码无效,必须用kapt,而且kapt的处理器依赖要单独声明一遍。

dependencies { implementation "org.seasar.doma:doma-core:${domaVersion}" annotationProcessor "org.seasar.doma:doma-processor:${domaVersion}" } // Kotlin 项目额外需要 plugins { id 'org.jetbrains.kotlin.kapt' } dependencies { kapt "org.seasar.doma:doma-processor:${domaVersion}" }

版本对齐这件事值得单独强调。doma-core、doma-processor、Spring Boot starter 这三者的版本必须严格一致。处理器生成的实现类会调用核心包里的 API,如果处理器版本比核心包新,生成的代码可能引用核心包里还不存在的类或方法,编译期报"找不到符号",而这个符号名你在项目源码里根本搜不到,因为它只存在于生成的代码里。遇到这种报错,第一反应应该是去看生成目录里的源码,而不是在业务代码里找。

2.3 与 Lombok、Kotlin 及 IDE 增量编译的冲突处理

Lombok 和 Doma 处理器同时存在时,顺序很重要。Lombok 要在 Doma 之前跑,因为 Doma 需要读实体的字段和 getter/setter 来做映射校验,而 Lombok 负责生成这些方法。Maven 里annotationProcessorPaths中声明的顺序就是执行顺序,把 Lombok 放前面即可。Gradle 侧则需要注意annotationProcessor依赖的声明顺序,虽然 Gradle 不保证严格按声明顺序执行,但实践中把 Lombok 放第一个基本能稳定工作。

Kotlin 的问题更麻烦一些。Kotlin 的属性访问器和 Java 的字段访问语义不完全一致,Doma 默认走字段访问,所以实体类最好写成 Java 类,或者在 Kotlin 里用@JvmField标注。如果整个项目都是 Kotlin,那实体层单独用 Java 写几行是成本最低的方案,我见过的几个混编项目最后都是这么处理的。

IDE 增量编译的问题前面提过一次,这里补一个实操细节:IntelliJ 的"Build project automatically"打开后,注解处理器有时不会重新执行,导致你改完 SQL 文件后生成的校验结果还是旧的。稳妥做法是把 SQL 文件和实体类的修改都触发一次完整 Rebuild,或者在运行配置里勾上"Build"步骤。这个操作听起来很土,但它确实能挡掉相当一部分"我明明改了啊"的困惑。

3. SQL 文件定位与 2-way SQL 语法细节

3.1 SQL 文件放在哪:默认约定与自定义路径的取舍

默认规则是:SQL 文件放在 classpath 下META-INF目录中,子目录结构与 DAO 接口的全限定名一致,文件名与方法名一致,扩展名.sql。举个例子,接口是com.example.dao.PersonDao,方法叫selectById,那文件就应该是src/main/resources/META-INF/com/example/dao/PersonDao/selectById.sql。这个约定很死板,但只要遵守就永远不出错。

实践中引发的第一个问题是资源过滤。Maven 默认会对src/main/resources下的文件做变量替换(${...}占位符),而 SQL 里恰好可能出现${...}这种写法——比如某些数据库的 JSON 函数、或者你从别的系统拷过来的模板片段。一旦被过滤,${后面找不到对应属性,构建会直接失败,或者更糟,被替换成了空字符串,SQL 静默地变了语义。解决办法是在pom.xml里把.sql排除出过滤范围。

<resources> <resource> <directory>src/main/resources</directory> <filtering>true</filtering> <excludes> <exclude>**/*.sql</exclude> </excludes> </resource> <resource> <directory>src/main/resources</directory> <filtering>false</filtering> <includes> <include>**/*.sql</include> </includes> </resource> </resources>

第二个问题是路径自定义。有些团队不喜欢META-INF这个目录名,想换成sql/。这需要实现自定义的SqlFileRepository并注入到Config里。我的建议是:除非有强约束,否则别改。改这一处的收益很小,但会破坏所有人的肌肉记忆,新人接手时第一件事就是找不到 SQL 在哪。如果非要改,务必在项目 README 里用一行字写清楚,并且把自定义实现的类名也写上。

第三个问题最隐蔽:大小写。在大小写敏感的文件系统上(Linux 容器里最常见),PersonDao和persondao是两个不同的目录。开发机是 macOS 或 Windows 时不报错,一上容器就SQL file not found。这类问题排查起来特别费劲,因为本地怎么都复现不了。稳妥做法是让接口名、目录名、文件名三者的拼写完全照抄,不要靠 IDE 自动补全去猜。

注意:如果项目打成 fat jar 后运行报找不到 SQL 文件,最快的验证方式是unzip -l app.jar | grep '\.sql',看实际打进去的路径是什么。这一步能直接排除掉 90% 的路径问题。

3.2 2-way SQL 的六类注释块与高频语法错误

2-way SQL 是这套框架最有辨识度的东西,也是语法错误的高发区。它的思路是让同一条 SQL 既能直接在数据库客户端里跑(注释被忽略),又能在框架里被解析成动态 SQL(注释被解释)。这个设计对调试极其友好,代价是注释的写法必须严格。

常用块大致有这几类:

语法块作用典型写法
条件块按条件拼 SQL 片段/*%if condition */ ... /*%end*/
循环块按集合展开片段/*%for item : items */ ... /*%end*/
绑定变量把方法参数绑到占位符/* paramName */'x'
字面量嵌入直接把值拼进 SQL/*# value */'x'
嵌入式表达式拼接排序、表名等结构性片段/*^ orderBy */dummy
展开块复用另一段 SQL/*%expand */配合条件

高频错误基本集中在四个地方。第一,/*%if后面必须有空格,/*%if(xxx)*/这种写法在某些版本上解析不出来,因为解析器按空白切分关键字。第二,/*%end*/必须成对,且不能写在--行注释里——写进去就变成普通注释,解析器完全看不到,报错信息会指向一个看起来毫无问题的位置。第三,/*%for的集合变量名要和方法参数名严格一致,而参数名能不能保留下来,取决于有没有开-parameters,这就是上一节反复强调它的原因。第四,绑定变量的注释只写参数名,不要写成/* param */:value这种混搭,注释里是参数标识,占位符本身该是什么还是什么。

select id, name, created_at from person where 1 = 1 /*%if name != null */ and name like /* name */'%foo%' /*%end*/ /*%if ids != null && ids.size() > 0 */ and id in ( /*%for id : ids */ /* id */1 /*%if id_has_next */,/*%end*/ /*%end*/ ) /*%end*/ order by id

上面这段里,/* id */1的写法是标准套路:注释里是参数名,后面紧跟一个占位值,占位值的作用是让这段 SQL 在数据库客户端里直接可执行(id in (1)是合法语法),框架解析时会把1替换成真正的绑定参数。这个"占位值必须是合法 SQL 字面量"的约束很多人不知道,写成/* id */?在客户端里就跑不通了,调试时又是一轮困惑。

3.3 编码、换行符与"看起来一样但不相等"的坑

SQL 文件必须是 UTF-8,这一点在 Windows 开发机上尤其危险。如果文件被保存成了带 BOM 的 UTF-8,文件开头会多出不可见的三个字节,框架读到的第一条语句开头就带着垃圾字符,报出来的语法错误位置永远对不上。判断方法是用十六进制查看文件头,EF BB BF开头就是 BOM,需要去掉。

换行符的问题同样烦人。某些方言的 SQL 解析器对\r\n和\n的处理不一致,尤其是跨多行的字符串字面量和--行注释。行注释的结束标志是换行符,如果文件混用了两种换行符,某一行注释的结束位置就可能落在意料之外,导致后面几行 SQL 被一并吞进注释里。这类问题的表现是"SQL 明明写了但执行时少了半截",非常难查。

解决办法是统一的:在项目根目录加.gitattributes,把*.sql强制成text eol=lf,编辑器里也统一设置成 LF。这个改动只花两分钟,但能省掉后面无数个小时。我现在的习惯是新建 SQL 文件之后第一件事就是确认编码和换行符,比写 SQL 本身还早一步。

4. 实体映射、命名策略与域类型转换

4.1 Entity 注解与命名策略的选择逻辑

实体映射的核心就三个注解:@Entity标类,@Table指定表名,@Id标主键。看起来简单,但命名策略这一项能玩出很多花样。默认情况下,Java 的驼峰属性名会按某种规则映射到列名,而不同团队的数据库规范差别巨大——有人喜欢snake_case,有人全大写下划线,还有人坚持驼峰列名。这时候就需要在配置里指定命名策略。

@Entity @Table(name = "person") public class Person { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; /** 对应列 person_name */ private String personName; @Column(name = "created_at") private LocalDateTime createdAt; @Version private Long version; @OriginalStates Person originalStates; }

选择逻辑是这样的:如果全库列名规范统一,就在全局配置里设一次命名策略,实体上什么都不用标,代码最干净。如果只是个别表不规范,就用@Column单独指定,别为了几张历史遗留表把全局策略改掉。我见过一个项目全局设了下划线转大写,然后几十个实体上又各自写了一遍@Column,两套规则打架,最后没人说得清某个字段到底映射到哪一列。

@OriginalStates这个注解值得单独说一句。它的作用是让框架记住实体被查询出来时的原始值,更新时只把发生变化的字段拼进update语句。这在"多人协作编辑同一行、但各自只改自己那块"的场景下很有价值,能避免把别人刚改的字段覆盖回旧值。代价是实体里多一个字段,并且这个字段不能被序列化出去,记得加忽略注解。

4.2 Domain 类与转换器:值对象落地的关键

这套框架对"值对象"的支持是它区别于其他方案的一大特色。你可以定义一个PhoneNumber类,标上@Domain,然后实体字段直接用这个类型,框架负责在 JDBC 类型和你的值对象之间做转换。

@Domain( valueType = String.class, factoryMethod = "of", accessorMethod = "getValue", acceptNull = true ) public class PhoneNumber { private final String value; private PhoneNumber(String value) { this.value = value; } public static PhoneNumber of(String value) { if (value != null && !value.matches("\\d{7,15}")) { throw new IllegalArgumentException("invalid phone: " + value); } return new PhoneNumber(value); } public String getValue() { return value; } }

这四个属性的含义要弄清楚:valueType是底层 JDBC 类型,factoryMethod是从底层值构造值对象的静态方法名,accessorMethod是从值对象取出底层值的实例方法名,acceptNull决定null是直接传进工厂方法还是被框架短路掉。最后一个参数最容易踩坑——设成false时,数据库里的null会让框架直接不调用工厂方法而返回null,如果你的业务逻辑依赖工厂方法做空值兜底,就会失效。

转换器(Converter)是更重量级的扩展点,用于处理数据库类型和 Java 类型之间的非标准映射,比如把jsonb列映射成一个自定义对象。转换器要注册到全局配置里,注册顺序会影响匹配优先级,越具体的类型越要靠前。这块我的经验是:能不用转换器就不用。转换器是全局生效的,一旦写错影响面是整个应用,而@Domain是字段级可控的,出问题只影响一个字段。除非确实是数据库类型层面的特殊映射,否则优先用@Domain。

4.3 空值、Optional 与基础类型的三条铁律

第一条:实体字段一律用包装类型,不要用基本类型。数据库列可空,而int、long无法表达null,框架在映射时会给你一个默认值 0,然后业务代码里就出现了"明明是空值却显示 0"的诡异现象。这种 bug 排查起来特别痛苦,因为从代码上看不出任何问题。

第二条:查询方法的返回类型要和方法语义匹配。期望最多一条用实体类型(查不到返回null),期望恰好一条用实体类型(查不到抛NoResultException,这个行为由方法上的注解属性控制),期望可能有多条用List。这三者的区别要在方法签名上体现出来,不要全都返回List然后在调用方判空判长度,那等于把框架提供的语义保证全丢了。

第三条:Optional可以用,但别滥用。用Optional做返回类型能强制调用方处理空值,这是好事。但把它用在实体字段上就是灾难——序列化、反射赋值、equals全都要特殊处理。我的做法是只在 DAO 方法的返回类型上用Optional,实体字段一律用可空包装类型。

提示:如果某个字段在数据库里是not null但有默认值,别指望框架帮你填默认值。insert时不传这个字段,走的是数据库默认值;但如果你传了null,那就是显式写入null,会直接违反约束。这两种情况的区别在动态 SQL 里尤其容易搞混。

5. 事务、批量与主键回填的实操细节

5.1 事务边界到底该划在哪一层

这套框架本身提供的是编程式事务:拿到TransactionManager,begin、commit、rollback三步走。这种写法在纯 JDBC 风格的项目里没问题,但在依赖注入容器里就很别扭,因为事务边界通常应该由容器来管。

Spring 环境下正确的做法是:把事务完全交给 Spring 的PlatformTransactionManager,DAO 层只负责执行,不碰事务 API。配置类里那个transactionManager属性指向框架自己的实现时,会和 Spring 的事务管理产生两套并行的边界,典型症状是"外层事务回滚了,但某条 SQL 已经提交了"。

@Service public class PersonService { private final PersonDao personDao; public PersonService(PersonDao personDao) { this.personDao = personDao; } @Transactional(rollbackFor = Exception.class) public void register(Person person, AuditLog log) { personDao.insert(person); // 同一事务内,任一步抛异常整体回滚 auditDao.insert(log); } }

排查事务问题有个很实用的技巧:在事务方法里打一条日志,把当前线程名和连接对象的哈希打出来,然后在 SQL 日志里对比。如果同一个事务里的多条 SQL 用的是不同连接,那事务边界肯定是断的。这个验证方法比读配置快得多,因为连接对象是运行时事实,配置写得再漂亮也骗不了它。

还有一个容易忽略的点:事务方法内部调用自身方法,注解不生效。这是代理机制的通用问题,不是这套框架特有的,但因为 DAO 调用通常很密集,症状会表现为"某几条 SQL 没回滚",很容易被误判成框架的 bug。

5.2 批量操作的参数选择与性能实测思路

批量插入、批量更新、批量删除各有对应注解,参数统一是Iterable。用法不难,难的是批量大小怎么定。太小了网络往返次数多,太大了单条 SQL 过长,可能撞上数据库的包大小限制或者驱动缓冲区上限。

我的估算方法是这样的:先量出单条记录的 SQL 文本大致字节数(把字段值和占位符都算进去),再拿数据库端的单条语句大小上限除以它,得到一个理论上限,然后取这个上限的 1/4 到 1/2 作为实际批量大小,留足余量。举个具体例子:单条记录大约 400 字节,数据库端限制是 4MB,理论上一批能放一万条,但实际我会取 1000 到 2000。原因是批处理失败的定位成本随批量线性上升——一批一万条挂了,你得先二分才能找到那条坏数据。

@BatchInsert int[] batchInsert(List<Person> people);

返回值是int[],每个元素对应一条记录的受影响行数。这个返回值很多人直接丢掉,但它其实是最好的校验手段:正常情况应该全 1,出现 0 说明某条记录被静默跳过了(比如触发了insert ignore或者条件不满足),出现 2 说明某条update匹配到了多行。在批处理之后加一行断言,能挡掉大量数据一致性问题。

还有一个性能上的细节:批处理在一个事务里执行时,回滚日志会随批量增长。如果批量很大且事务很长,数据库端的回滚段压力会很明显。分多个小批次提交(比如每 1000 条一个事务)通常比一个大事务更快,虽然总事务数变多了,但锁持有时间和回滚段占用都下来了。这个取舍要看具体场景:如果业务上必须原子提交,那就只能大事务,靠调小批量来缓解。

5.3 自增主键回填不生效的排查路径

实体主键标了自增策略之后,insert执行完,主键值应该被回填到实体对象上。这件事不生效的时候,症状是插入成功了但实体主键还是null,后续用这个实体做关联操作就全乱套。

排查顺序是这样的。第一步,确认主键字段确实同时标了@Id和自增策略,两个都不能少。第二步,确认方法上没有手工指定要插入的列清单把主键排除掉了——有些团队为了复用 SQL 会写include或exclude属性,把主键排除在插入列之外,这时候驱动层面就不会返回生成键。第三步,看数据库和驱动是否支持批量场景下的生成键返回。单条插入一般没问题,批量插入时部分驱动只返回第一行的键,其余为null,这种情况必须回退成逐条插入,或者改用带returning子句的方言写法。

@Insert int insert(Person person); // 调用后 person.getId() 应不为 null personDao.insert(person); Long id = person.getId();

如果确认配置都没问题但还是拿不到,可以在 SQL 日志里看实际执行的语句。有些场景下框架会走"先查序列再插入"的路径而不是"插入后取生成键",这时候回填逻辑是不一样的。日志里能看到真实语句,比看文档猜快得多。

注意:自增主键回填依赖 JDBC 驱动的getGeneratedKeys实现。如果项目里换过驱动版本或者换过连接池,回填行为有可能变化。升级驱动之后建议把这条路径的集成测试重跑一遍,别等上线才发现。

6. 运行时异常速查与可观测性建设

6.1 一份能直接对号入座的异常对照表

运行时异常看起来五花八门,实际归归类就那么几种。下表是我这些年攒下来的,遇到问题先在这张表里找一行,再决定往哪个方向查。

异常/现象真实含义常见根因处理方向
NoResultException期望恰好一条,实际零条查询条件不匹配、数据被删确认业务语义,改返回类型或补数据
NonUniqueResultException期望一条,实际多条唯一键缺失、条件写漏补条件或改返回List
唯一约束异常插入/更新违反唯一索引并发重复提交、逻辑删除标记未参与唯一键加幂等控制、调整索引设计
乐观锁异常版本号不匹配并发更新同一行、版本字段未正确加载捕获后重试或提示用户刷新
SQL 执行异常语句本身有问题语法错误、列名拼错、类型不匹配打开 SQL 日志看真实语句
配置未初始化异常全局配置没被设置手工new实现类但没传配置、容器未注册配置 bean检查装配方式
类型转换异常数据库类型与 Java 类型不匹配@Domain配置错、转换器优先级冲突逐个字段验证映射关系

乐观锁异常值得展开说两句。它的触发条件是更新时where条件里的版本号与数据库当前值不一致,说明这行数据在你读取之后被别人改过。框架抛异常是正确行为,问题在于业务层怎么处理。我的做法是:对用户直接操作的场景,捕获后提示"数据已被修改,请刷新后重试";对后台批处理场景,捕获后重新读取再重试,重试次数限制在三次以内并记录日志。最怕的是把异常吞掉当成功,那样数据就悄悄丢了。

6.2 让 SQL 日志真正可用

默认的日志实现往往只打出 SQL 骨架,参数值用占位符代替,排查时等于看天书。要让它真正可用,需要换一个能输出参数值和执行耗时的日志实现,并在配置里挂上去。

public class AppConfig implements Config { private final DataSource dataSource; public AppConfig(DataSource dataSource) { this.dataSource = dataSource; } @Override public DataSource getDataSource() { return dataSource; } @Override public Dialect getDialect() { return new PostgresDialect(); } @Override public JdbcLogger getJdbcLogger() { return new Slf4jJdbcLogger(); } }

三个属性是必须的:数据源、方言、日志。方言这一项如果配错,症状往往不是报错,而是分页语句、limit语法、字符串拼接函数这些"方言敏感"的地方悄悄出错。比如配成了另一个数据库的方言,分页查询可能生成语法不兼容的语句,报出来的错误指向的位置跟分页完全无关。我现在的习惯是:新建项目时先把方言配置写死成一个常量,并且在这个常量旁边写一行注释说明数据库类型,避免后来人改错。

日志级别也要调。SQL 日志通常打在 DEBUG 级别,生产环境默认不开是对的,但预发环境建议常开,出问题时不用重启就能看到真实语句。参数值里有敏感字段的话,在日志实现里做一层脱敏,别把原始值直接落盘。

还有一个进阶技巧:给日志加上调用方的类名和方法名。多数据源或者多模块的项目里,光看 SQL 很难判断是谁发的,加上调用栈信息之后排查效率提升非常明显。实现方式是在JdbcLogger的包装类里取当前线程栈,把前几帧的类名拼进日志前缀。这个改造成本不高,收益很大。

7. 几个不值钱但很省时间的实操习惯

第一个习惯:每次动 SQL 文件,先跑一次完整构建。别指望 IDE 的实时校验,它和后端的校验规则不完全一致。命令行构建通过之后再去看 IDE 的报红,两边都绿了才算真的没问题。

第二个习惯:给每个 DAO 方法写一行注释说明返回语义。"查不到返回 null"和"查不到抛异常"这两种语义在方法签名上看起来一模一样,半年后接手的人(可能就是你自己)一定会搞混。一行注释的成本,能省掉一次线上事故。

第三个习惯:SQL 里永远显式列出列名,不用select *。除了性能上的考虑,更重要的是这直接决定编译期校验能不能生效。列名写全了,实体属性和查询列对不上的问题在编译阶段就能拦住;写成select *,框架没法在编译期推断列集合,校验就退化成运行时才暴露。

第四个习惯:测试里至少留一条覆盖动态条件分支的用例。2-way SQL 的条件块在编译期只能做语法校验,逻辑分支对不对要靠测试。我见过一个/*%if*/里条件写反了,导致某个筛选条件永远不生效,测试环境数据少没人发现,上了生产才被业务方投诉。一个带参数化的用例就能覆盖住,成本极低。

第五个习惯:升级框架版本之前,先看生成目录里 diff 了什么。生成的实现类代码变化往往比更新日志更能说明问题,尤其是涉及事务、批处理、主键回填这几块的时候。把新旧两版生成的代码 diff 一遍,风险点一目了然,比读一堆发布说明高效得多。

第六个习惯,也是我个人觉得最值钱的一条:遇到问题先看消息码,别从头读堆栈。那串带数字的码值是框架作者精心设计的定位入口,同一个码永远对应同一类问题。把它们整理成一个自己的小抄,贴在项目 wiki 里,团队里每个人都能受益。我带过的几个项目里,这份小抄的访问量长期排在前三,比任何架构文档都实用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 20:56:02

自建本地AI配音工作台VoiceStudio:从选型到踩坑全复盘

我给自己搭了一个本地AI配音工作台&#xff0c;代号VoiceStudio&#xff0c;前后折腾了差不多一个季度。之所以动手&#xff0c;是因为我每周至少要产出两期解说类视频&#xff0c;配音文本量上来了之后&#xff0c;云端TTS的账单、生硬的机器腔和脚本数据外流这三件事轮流折磨…

作者头像 李华
网站建设 2026/11/2 20:52:24

LoRa温湿度监测系统设计与低功耗优化实践

1. LoRa温湿度在线监测系统概述在工业物联网和智慧农业领域&#xff0c;温湿度监测一直是个经典但棘手的需求。传统有线方案布线成本高&#xff0c;无线方案又面临传输距离和功耗的平衡难题。我们团队最近落地的LoRa温湿度监测系统&#xff0c;采用Semtech的LoRa扩频技术&#…

作者头像 李华
网站建设 2026/11/2 20:50:34

Cesium后处理数据传递:uniform类型对齐与纹理实战

三维场景里做后处理&#xff0c;最让人抓耳挠腮的阶段往往不是写着色器本身&#xff0c;而是把数据从 JavaScript 那一侧准确无误地送进 GPU。我第一次给 Cesium 加自定义后处理时&#xff0c;颜色参数怎么调都不对&#xff0c;折腾了半天才发现是 uniform 声明的类型和传入的数…

作者头像 李华
网站建设 2026/11/2 20:45:29

Qt5/Qt6 工程接入 QFtp:FTP 客户端异步状态机与断点续传实践

简介&#xff1a;面向Qt5及以上版本开发者的QFtp移植与测试示例&#xff0c;针对新版Qt不再提供QFtp类的现实问题&#xff0c;将Qt4.8.7中的QFtp源码抽取为两个核心文件并配套可运行的FTP客户端工程&#xff0c;便于理解旧模块迁移、接口封装与工程适配思路。压缩包共17个文件、…

作者头像 李华
网站建设 2026/11/2 20:45:18

HDFS分布式存储核心原理与实战指南

1. 大数据分布式存储入门指南第一次接触HDFS时&#xff0c;我被它处理PB级数据的能力震撼到了。记得当时用三台二手服务器搭建集群&#xff0c;当看到文件被自动切块分布存储时&#xff0c;那种"原来大数据是这样玩的"的顿悟感至今难忘。作为大数据生态的基石&#x…

作者头像 李华
网站建设 2026/11/2 20:42:27

JVM垃圾回收机制与触发条件详解

1. JVM垃圾回收机制概述Java虚拟机(JVM)的垃圾回收机制是Java语言区别于C/C等手动内存管理语言的核心特性之一。作为自动内存管理系统&#xff0c;它负责监控堆内存中对象的生命周期&#xff0c;识别并回收不再被引用的对象所占用的内存空间。这种机制有效避免了内存泄漏和野指…

作者头像 李华