金额已经传入,为什么读回来变了

订单金额是 new BigDecimal("12.345"),数据库列是 numeric(19,2)。Java 对象可以保留三位小数,数据库列只能保留两位。调用 persist 的参数类型正确,不代表这两个数值域相同;写入成功也不代表重新查询能得到逐位相同的数。

这个问题包含三次转换:Java 值转换为 JDBC 可绑定的值,驱动把值交给服务器,服务器按实际列定义接受或拒绝它。注解描述映射,不是每次 setter 执行的校验器。@Column(scale = 2) 不会自动把 Java 字段改成两位小数,validate 也不能代替业务金额规则。

本篇固定 Hibernate ORM 7.1.36.Final 与 JDK 21。完整实验是仓库中的 examples/hibernate-lab/src/test/java/blog/hibernate/Chapter04Test.java,不需要从文中拼接代码。本地 PostgreSQL 17.6 的三项测试通过,真实输出见 examples/hibernate-lab/evidence/04/20261002-pg17-direct/;首次失败保留在相邻的 20261002-pg17/。MySQL 对照尚未运行,不以 PostgreSQL 的结果代替。

类型映射解决的是两端契约

JavaType 描述 Java 表示:怎样判等、复制、包装与解包,以及在当前配置下推荐哪种 JDBC 类型。JdbcType 描述 JDBC 表示:SQL 类型代码、参数绑定器、结果提取器。基本属性的运行时映射把这两类描述组合起来,最终由 binder 把属性值写入 PreparedStatement 的对应位置。

flowchart TD
    A["BigDecimal 属性值"] --> B["JavaType:Java 表示与 unwrap"]
    B --> C["JdbcType:选择 ValueBinder"]
    C --> D{"值是否为 null?"}
    D -- 是 --> E["setNull:绑定 SQL 类型"]
    D -- 否 --> F["setBigDecimal:绑定十进制值"]
    E --> G["驱动执行请求"]
    F --> G
    G --> H["数据库按真实列定义存储"]
    H --> I["另一连接读取终态"]

在冻结源码中,BasicBinder.bind 先检查 null:null 走 doBindNull,非 null 才走具体 doBind。DecimalJdbcType.getBinder 的实现将值经 javaType.unwrap(..., BigDecimal.class, ...) 解包后调用 setBigDecimal。默认 BigDecimal 映射使用 NUMERIC 描述,相关实现沿用这一绑定机制;不能因为文中画了 DECIMAL 的类,就把所有数据库最终列型都称为 DECIMAL。

这条路径没有“按注解 scale 调用字段 setter”的步骤。数据库写入改变的是持久化表示,不会逆向修改当前对象。BigDecimalJavaType 的相等判断和 JDBC 绑定又属于不同任务:脏检查需要判断属性是否变了,绑定需要把已有值传给驱动。Java BigDecimal.equals 的 scale 敏感性不能直接推出 Hibernate 的脏检查结果。

上游 BigDecimalMappingTests.testMappings 同时断言 Java 描述是 BigDecimal、JDBC 描述是 NUMERIC,再做写入与加载。它验证类型选择和基本往返,没有证明三位小数进入两位列时会发生什么。教学实验必须补上真实列约束和数据库读取,才能回答开头的金额问题。

将表示检查与业务检查分开

金额规则应在进入持久化层之前确定。例如要求输入最多两位小数,可用 amount.setScale(2, RoundingMode.UNNECESSARY) 拒绝超出精度的值;若业务允许舍入,应明确舍入模式,再把舍入后的值赋给实体。直接让数据库舍入会把规则分散到数据库类型、方言与版本中,且当前托管对象可能仍保留舍入前的值。

可迁移的检查次序是 业务数值域 → Java 表示 → JDBC 表示 → 实际列定义 → 重新读取。金额、数量和百分比共享这条链,但各自的精度、舍入和拒绝条件不同。换成 double 会增加二进制浮点表示误差,不能用于消除这两端契约的差异。

时间类型保存了哪些信息

Instant 表示时间轴上的点,LocalDateTime 表示不含时区的当地日期时间。两个类型可以显示出相似的年月日时分秒,却不能承担同一个业务含义。订单支付时刻适合时间轴语义;门店预约“当地上午八点半”还需要门店时区、夏令时歧义处理和业务日期规则,只有一个 LocalDateTime 不足以恢复全球唯一时刻。

实验把 2026-10-02T00:30:00Z 写进 timestamp with time zone,把 2026-10-02T08:30:00 写进 timestamp without time zone。另一 JDBC 连接设置 Asia/Shanghai 后,前者的文本显示应转换到 +08,但读成 OffsetDateTime 再转换为 Instant,仍应等于原时间点;后者的本地字段应原样保持。

PostgreSQL 的 timestamp with time zone 不保留原来的地理时区名。上海和其他采用相同偏移的地区,在同一个时间点可能得到相同时间值;不能据此恢复“Asia/Shanghai”这一业务信息。需要原区域时,应独立保存 ZoneId 或业务区域字段。

hibernate.jdbc.time_zone=UTC 是绑定/读取配置的一部分,不能给 LocalDateTime 增加原本不存在的区域信息。首次实验在 JVM 时区 Asia/Shanghai 下使用默认 Timestamp 路径,Hibernate 回读得到 08:30,独立 JDBC 读取却得到 00:30。只验证 ORM 往返会漏掉这个差异。

源码解释了差异:LocalDateTimeJavaType 将当地字段解包成 Timestamp;TimestampJdbcType 在设置 JDBC 时区时使用 UTC Calendar 绑定,读取又用相同 Calendar,正反转换恢复了对象字段,但数据库里的当地字段已经偏移。完整反例在 legacyTimestampBindingCanRoundTripWhileDatabaseLocalFieldsShift 中单独保留,断言独立读取的值是经 JVM 时区解释后转换到 UTC 的字段,而不是削弱原来失败的断言。

对照组启用 hibernate.type.java_time_use_direct_jdbc=true。冻结 LocalDateTimeJavaType.getRecommendedJdbcType 此时选择 LOCAL_DATE_TIME,直接 Java Time 绑定保留当地字段;同一测试库独立 JDBC 读取恢复为 08:30。这个设置选择了绑定路径,并没有为 LocalDateTime 添加区域。InstantJavaType 的首选 SQL 类型也从配置指标取得,不能把一次配置推广到所有方言。Java 支持纳秒,实验列是微秒;本次样本没有纳秒尾数,因此没有验证精度截断边界。

枚举的名字和位置是两种编码

测试枚举是 State { NEW, PAID }。字符串映射应写入 PAID,序号映射应写入 1。二者都能在同一版本中往返,但迁移代价不同:在 NEW 前插入一个枚举常量,会改变旧 ordinal 值的含义;把 PAID 重命名,则会使旧字符串无法按新名字恢复。

字符串映射避免位置漂移,不等于枚举可以任意改名。稳定业务代码可由显式转换器定义,转换器的 null、未知代码和兼容迁移规则仍要测试。数据库约束能限制输入范围,也不能自动决定旧编码迁往哪个新编码。

本篇显式指定 EnumType.STRING 与 EnumType.ORDINAL,不依赖默认策略。@EnumeratedValue 等规范扩展不在本实验里,不能从这两个案例推断所有枚举都只能以名字或序号保存。实际数据库中的 varchar/smallint 列也不等同于数据库原生 enum 类型。

标识符必须与 INSERT 时点一起观察

sequence 可以在 INSERT 之前提供 id,identity 则由 INSERT 生成值。调用发生在活动事务中时,两种策略可能要求不同的 SQL 时序。实验将 allocationSize 明确设为 1,避免把池化优化器提前领取一段编号的行为混进单次生成比较。

1
2
3
sequence:begin → persist → 取得 sequence 值 → 返回 id → flush/提交时 INSERT
identity:begin → persist → INSERT 并取得生成键 → 返回 id
两者:rollback → 新连接检查对应 id 的行不存在

这条时间线是本实验条件下要检查的分支。没有活动事务、延迟 identity 插入、特殊生成器和不同入口可能改变时点;不能将其简化为所有 persist 总是发 SQL,或所有 persist 总是不发 SQL。

冻结源码的 SequenceStyleGenerator.generate 调用 optimizer,再由数据库结构 callback 提供值。生成器也有根据数据库能力选择结构的路径;类名包含 Sequence 不足以证明每个方言都发同一种 nextval。上游 BasicSequenceTest.testNormalBoundary 断言生成器类型、得到的 id 和数据库结构访问次数,教学实验再补动作时点、INSERT 文本和回滚终态。

不把分配编号当成提交凭据

标识符分配、语句执行和事务提交是三个事件。身份可以先确定,行可以先在当前事务里存在,其他事务仍未必看得见。回滚后的 Java 对象可能还持有 id;sequence 空洞也不能作为丢订单的统计依据。

这个区分同样适用于业务号与幂等键:业务号是否已分配、唯一约束是否已接受、本次请求是否确定提交,分别需要证据。用 id != null 判断订单创建成功,会把失败分支合并掉。

三个实验分别排除什么解释

sequenceAllocatesBeforeInsertAndIdentityInsertsBeforeReturning 收集 StatementInspector 文本。在 sequence 的 persist 之后检查已经调用 nextval、尚无 INSERT;identity 的 persist 返回后检查出现对应 INSERT。两者回滚后,另起 JDBC 连接按 id 查询行数为零。SQL 文本回调只证明 Hibernate 生成了该文本,最终行数用数据库读取证明,不从回调条数推导网络往返或 JDBC batch。

serverRoundingAndTimeZoneDisplayDoNotMutateManagedFields 先写入 12.345,flush 后检查对象字段仍为 12.345,再提交、clear、重新加载并检查 12.35。额外连接检查字符串/序号编码,以及设置显示时区后同一 Instant 的不变性。对象读取和数据库读取承担不同断言,缺少其中一端便无法解释“对象没变,数据库变了”。

最终运行使用 PostgreSQL 17.6、Corretto 21.0.11、Maven 3.9.13、驱动 42.7.7;JVM 时区固定 Asia/Shanghai,绑定时区固定 UTC。原始输出的关键行如下,完整上下文与退出码见上述证据目录:

1
2
3
4
CH04 LEGACY jvm-zone=Asia/Shanghai object=2026-10-02T08:30 database=2026-10-02T00:30
CH04 DB amount=12.35 instant-display=2026-10-02 08:30:00+08 appointment=2026-10-02 08:30:00.0 string=PAID ordinal=1
CH04 ROLLBACK sequence-row=0 identity-row=0
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0

复跑须连接专用实验数据库,完整命令在工程目录执行:

1
./mvnw -B -ntp -Duser.timezone=Asia/Shanghai -Dtest=Chapter04Test test

环境变量、JDK 与 Wrapper 见工程 README。本轮使用安装好的 Maven 3.9.13,Wrapper 仍固定 3.9.9,具体运行命令见证据 RUN.md。测试只建 chapter04 专用表,不修改订单主表;仍不能在生产或共享数据库运行。成功日志包括环境、命令、退出码、三项测试汇总和 CH04 数据库输出。

手算题:在枚举 NEW 前插入 CREATED,旧库中的整数 1 在新程序里对应什么?答案是 NEW,不再是 PAID。改动练习:把金额改为 12.344,预测数据库两位小数结果;再在调用 persist 前加入 setScale(2, UNNECESSARY),比较异常发生在 Java 业务检查还是数据库执行阶段。预测属于推导,修改后的结果须另存运行记录。

症状 要核对的契约 可靠的观察点
写入金额和读回金额不同 业务舍入、Java 值、实际 scale flush 后对象与独立连接读取
同一时刻显示不同 时间轴语义、显示时区、列型 转为 Instant 后比较
枚举升级后旧值错位 名字或位置编码与迁移 旧库样本在新映射下读取
id 已有但订单不存在 生成、执行、提交的不同阶段 回滚后新事务按幂等键/id 查终态

源码与实验入口

上一篇:03:托管与脱管怎样影响订单。下一篇:05:persist 返回时究竟完成了什么。