Old-School Backend / 2026 ・ No.02

古法后端入门

第2讲 Java 准备 ── 正确地存放钱、日期和时刻

发布

终端界面。在 jshell 中,0.1 + 0.2 是 0.30000000000000004,1100 / 1.1 是 999.9999999999999,Math.floor(1100 / 1.1) 是 999.0。BigDecimal 的 1100 除以 1.1 是 1000,new BigDecimal(0.1) 是 0.1000000000000000055511151231257827021181583404541015625,BigDecimal 的 1280 和 1280.00 的 equals 是 false
在 jshell(可以一行一行试 Java 的工具)里比较 double 和 BigDecimal。用 double 算含税 1,100 日元的不含税金额并舍去,结果是 999 日元;用 BigDecimal 是 1,000 日元(笔者在云端 Linux 上于 2026 年 10 月 26 日截图。窗口上方的标题栏是为了说明而后加的)

第 1 讲的作业是:“申请日期是 26 日,可回复的 Date 是 25 日,日志是 15 点多,数据库是 0 点多。哪个是对的?”
这一讲是 Java 的准备。以业务系统里最容易出事故的钱和日期、时刻为中心,再加上 null、record、列表、字符串这些“不知道就会白白浪费一天”的地方,用 jshell 和小程序逐一实测。

一

对答案之前

新井作业的答案是“全都对”,没错吧?

古井对。不过说不清为什么对,总有一天你会开出差了 9 小时的账单。

新井9 小时……

古井还有差了 1 日元的账单。今天从正确地存放钱和时刻开始。

全 15 讲路线图(当前位置:第 2 讲)

1 全景图 2 Java 准备 3 裸写 HTTP 4 裸写 JDBC 5 Spring Boot 6 输入检查 7 异常与回复 8 数据库访问 9 事务 10 状态流转 11 认证与授权 12 文件 13 日志与监控 14 测试 15 运行起来 当前位置 基础 徒手 入口 数据 业务与防护 运维
  • 钱 ── 用 double 算含税 1,100 日元的不含税金额得 999 日元。BigDecimal 的创建、比较、舍入(实测)
  • 日期和时刻 ── 同一瞬间,日期也会因地方而变。JVM 和数据库的时区设置(实测)
  • 旧工具的陷阱 ── 共用 SimpleDateFormat,8 万次中有 4,763 次日期出错(实测)
  • null、record、字符串 ── 3 个字符的“交通費”是 9 字节(实测与 Wireshark)

实验用的是 JDK 25 的 jshell(可以一行一行试 Java 的工具),以及像 java Lesson2.java 这样直接运行源文件的小程序。数据库是第 1 讲的 PostgreSQL。

二

钱不要用 double

在 Java 里处理小数,最先学到的类型是 double。用它来算报销会怎样?试一试。

# 用 double(二进制小数)算钱的话
    0.1 + 0.2                                    → 0.30000000000000004
    0.1 加 10 次                                 → 0.9999999999999999
# 含税 1,100 日元(10%)的不含税金额是?
    1100 / 1.1                                   → 999.9999999999999
    Math.floor(1100 / 1.1)                       → 999.0
    Math.floor(1080 / 1.08)                      → 999.0
# 把 0.29 美元换成美分
    (int) (0.29 * 100)                           → 28

0.1 + 0.2 不等于 0.3。用“除以 1.1 再舍去”求含税 1,100 日元的不含税金额,本该是 1,000 日元,却成了 999 日元。0.29 美元换成美分是 28 美分。对 1 日元到 10 万日元的所有含税金额做同样的计算,税率 10% 有 4,174 件、税率 8% 有 818 件差了 1 日元。

0.1 在二进制里除不尽

十进制 1 / 3 = 0.3333333333… “3”无限循环 二进制 0.1 = 0.0001100110011001100… “0011”无限循环 double 在 52 位处截断,存放最接近的值 0.10000000000000000555… 误差很小,但加加减减之后就会显露出来 差 1 日元都不行的钱,要用按十进制计数的 BigDecimal

double 用二进制存数。就像十进制里 1/3 除不尽一样,二进制里 0.1 也除不尽,存的是在 52 位截断后“最接近的值”。平时显示时会被舍入看不出来,但加减乘除多了就会露出来。对科学计算来说小到可以忽略,可钱差 1 日元都不行。

三

BigDecimal 的规矩

钱要用 BigDecimal。它按十进制存数,位数要多少有多少。不过用法有规矩。

# BigDecimal 的创建方法
    new BigDecimal(0.1)                          → 0.1000000000000000055511151231257827021181583404541015625
    new BigDecimal("0.1")                        → 0.1
    BigDecimal.valueOf(0.1)                      → 0.1
# 计算
    0.1 + 0.2                                    → 0.3
    1100 / 1.1                                   → 1.00E+3
    (1100 / 1.1).toPlainString()                 → 1000
# 1280 和 1280.00 相同吗?
    a.equals(b)                                  → false
    a.compareTo(b)                               → 0
    new HashSet<>(List.of(a, b)).size()          → 2 个
  • 从字符串创建 ── new BigDecimal(0.1) 会把 double 的“最接近的值”原样抄过来,带上长长的尾巴。要用 new BigDecimal("0.1") 或 BigDecimal.valueOf(0.1)
  • 比较用 compareTo ── equals 连位数(scale)都比,所以 1280 和 1280.00 “不同”。第 1 讲从数据库返回的金额是 1280.00,和页面发来的 1280 用 equals 比,就不一致
  • 显示用 toPlainString ── 除法的结果有时会变成 1.00E+3 这样的指数形式

新井把 1280 和 1280.00 放进 HashSet 是 2 个……就是说查重不起作用?

古井对。要拿金额当键,先用 stripTrailingZeros() 或 setScale 统一位数。古时的账本,也是先对齐位数再核对的。

四

尾数怎么舍入

算钱一定会有尾数。BigDecimal 的设计是让你自己选舍入方式。

# 税 8%:1,285 日元 × 0.08
    tax = 1285 × 0.08                            → 102.80
    tax.setScale(0, RoundingMode.DOWN)           → 102
    tax.setScale(0, RoundingMode.HALF_UP)        → 103
# 尾数的舍入方式:HALF_UP / HALF_EVEN / DOWN / FLOOR
    2.5                                          → 3 / 2 / 2 / 2
    3.5                                          → 4 / 4 / 3 / 3
    -2.5                                         → -3 / -2 / -2 / -3
# 平摊:10,000 日元 3 个人分
    10000 / 3                                    → ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
    divide(3, 0, RoundingMode.DOWN)              → 3333
    divideAndRemainder(3)                        → [3333, 1]
# 合计(用 stream)
    1280 + 3300 + 880.50                         → 5460.50

同一个数,舍入方式不同结果就不同(实测)

舍入方式 2.5 3.5 -2.5 HALF_UP 即四舍五入 3 4 -3 HALF_EVEN 正好一半时取偶数 2 4 -2 DOWN 向 0 舍去 2 3 -2 FLOOR 向小的一方舍去 2 3 -3 红色是与四舍五入不同的结果。负数时 DOWN 和 FLOOR 会分开 用哪个不是技术问题,而是规则问题。税的尾数按公司规定或发票的单位来定 BigDecimal 不写舍入方式,除法会停下来(10000 / 3 抛出异常)

1,285 日元的 8% 是 102.80 日元。舍去是 102 日元,四舍五入是 103 日元。哪个对不是技术问题,而是规则问题,按公司规定或与客户的约定来定。定了之后,就集中写在代码的一处。

10,000 日元 3 个人平摊,BigDecimal 抛出“除不尽”的异常停了下来。不默默舍入,其实是一种体贴。指定舍去成整数是 3,333 日元,用 divideAndRemainder 还能得到余下的 1 日元。这 1 日元由谁付,也是业务规则。

五

日期和时刻:哪里的时钟

来对作业的答案。把与第 1 讲回复的 Date 头部相同的瞬间,用 4 个地方的时钟来表示。

# 与第 1 讲回复的 Date 相同的瞬间(Instant),用 4 个地方的时刻表示
    Instant                                      → 2026-10-25T15:47:43Z
    UTC                                          → 2026-10-25T15:47:43 Z  日期 2026-10-25
    Asia/Tokyo                                   → 2026-10-26T00:47:43 +09:00  日期 2026-10-26
    Asia/Shanghai                                → 2026-10-25T23:47:43 +08:00  日期 2026-10-25
    America/New_York                             → 2026-10-25T11:47:43 -04:00  日期 2026-10-25
    HTTP Date                                    → Sun, 25 Oct 2026 15:47:43 GMT

世界上唯一的瞬间,用各地的时钟来读

世界上唯一的瞬间(Instant) 2026-10-25T15:47:43Z UTC 15:47 10-25 Asia/Shanghai 23:47 10-25 Asia/Tokyo 00:47 10-26 America/New_York 11:47 10-25 时刻是“瞬间”和“当地时钟”的组合。都是同一瞬间,连日期都不同 第 1 讲的作业:回复的 Date 是 UTC,应用日志是 UTC,数据库日志是东京时间。全都对 像申请日这种“当地日历上的一天”用 LocalDate,记录的时刻用瞬间来存

时刻是世界上唯一的瞬间和用哪里的时钟读的组合。同一瞬间,在东京是 26 日 0 点 47 分,在 UTC 是 25 日 15 点 47 分。HTTP 的 Date 按规定是 GMT(UTC);第 1 讲的申请服务器在没有时区设置的状态下启动,所以是 UTC;数据库创建时的设置是东京,所以是东京的时钟。全都指向同一瞬间。

可怕的是 LocalDate.now() 这种“按这个 JVM 的时钟,今天几号?”的写法。在同一瞬间,用只有时区设置不同的两个 JVM 来问。

# 在同一瞬间,用只有时区设置不同的两个 JVM 询问“现在”
# JVM 的时区设置是 UTC
    LocalDate.now()          → 2026-10-25
    LocalDateTime.now()      → 2026-10-25T16:05:59
    Instant.now()            → 2026-10-25T16:05:59Z
# JVM 的时区设置是 Asia/Tokyo
    LocalDate.now()          → 2026-10-26
    LocalDateTime.now()      → 2026-10-26T01:06
    Instant.now()            → 2026-10-25T16:06:00Z

一个是 25 日,一个是 26 日。取决于服务器的设置,凌晨 0 点到 9 点之间提交的申请,“申请日”会变成前一天。像申请日这种用户在日历上选的日期,基本做法是从页面作为 LocalDate 接收,不能由服务器的“今天”来决定。

六

把时刻存进数据库

把时刻存进数据库也有同样的陷阱。PostgreSQL 有不带时差的 timestamp 类型,和作为瞬间存放的 timestamptz 类型。在同一瞬间,分别从 UTC 的 JVM 和东京的 JVM 各写一行。

# 在同一瞬间,UTC 的 JVM 和 Asia/Tokyo 的 JVM 各写一行
# wall=timestamp 类型存 LocalDateTime,moment=timestamptz 类型存 OffsetDateTime
ps.setObject(2, LocalDateTime.now());   // wall
ps.setObject(3, OffsetDateTime.now());  // moment
# 读回来(用哪个 JVM 读结果都一样)
    Asia/Tokyo  wall=2026-10-26T01:06:41  moment=2026-10-25T16:06:41Z
    UTC         wall=2026-10-25T16:06:41  moment=2026-10-25T16:06:41Z
终端界面。用 psql 显示两次 clock 表。jvm 列为 Asia/Tokyo 的行,wall 是 2026-10-26 01:06:41;UTC 的行是 2026-10-25 16:06:41。moment 列第 1 次两行都是 2026-10-26 01:06:41+09,加上 PGTZ=UTC 的第 2 次两行都是 2026-10-25 16:06:41+00
在同一瞬间,时区设置不同的两个 JVM 各写下“现在”的表。timestamp 类型的 wall 差了 9 小时,而 timestamptz 类型的 moment 是同一瞬间,换个显示地点,变的只是时钟上的数字(笔者在云端 Linux 上于 2026 年 10 月 26 日截图。窗口上方的标题栏是为了说明而后加的)

把 LocalDateTime 存进 timestamp 的 wall,明明是同一瞬间却差了 9 小时。因为里面只有“墙上时钟的数字”,把“哪里的时钟”丢掉了。存进 timestamptz 的 moment,两行都指向同一瞬间,换个显示地点,变的只是数字的样子。

Java 类型与数据库类型的对应

Java 类型 存的是什么 PostgreSQL 类型 Instant 世界上唯一的瞬间 timestamptz OffsetDateTime 瞬间+当地时差 timestamptz LocalDate 只有日历日期(申请日) date LocalDateTime 只有墙上时钟的数字 timestamp 记录的时刻要用“瞬间”来存。LocalDateTime 里没有“哪里的时钟” 实测:同一瞬间写入的 timestamp 差了 9 小时,timestamptz 完全相同(照片) 服务器的时区在启动时定好(例:-Duser.timezone=UTC)。显示按用户所在地
POINT

记录的时刻(创建时刻、批准时刻)用 Instant 或 OffsetDateTime,数据库用 timestamptz。申请日这种日历日期用 LocalDate 和 date。服务器的时区在启动时定好(比如 -Duser.timezone=UTC),给用户看时再换成用户所在地的时钟。

七

古老的 Date 和 SimpleDateFormat

旧代码里,日期常用 java.util.Date,格式常用 SimpleDateFormat。现场的旧系统里,一定也有。这两个有著名的陷阱。让 8 个线程共用一个 SimpleDateFormat,各读写日期 1 万次。

# 8 个线程共用一个格式对象,各读写日期 1 万次(次数每次运行都不同)
    SimpleDateFormat     → 日期不对 4763 次,异常 590 次
    DateTimeFormatter    → 日期不对 0 次,异常 0 次

8 万次里有 4,763 次变成了别的日期,590 次因异常停下(次数每次运行都不同)。SimpleDateFormat 内部有工作用的状态,同时使用就会互相改写。正如第 1 讲看到的,服务器一件一个线程同时运行,所以这会变成“偶尔混进奇怪日期”这种最难发现的缺陷。Java 8 起的 java.time(LocalDate、DateTimeFormatter 等)创建后就不会变(不可变),共用也是 0 次。

新井明明是“古法”连载,却说别用旧的啊。

古井古时的工匠从不怠慢工具的保养。了解旧工具的脾气,再换成新工具,也是手艺的一部分。

八

null 和 Optional

Java 的变量可以存放表示“什么都没有”的 null。直接使用 null,就会因 NullPointerException 停下。

# 项目为 null 的申请
    r.item().length()  → NullPointerException: Cannot invoke "String.length()" because the return value of "ExpenseRequest.item()" is null
# 查找不存在的受理编号
    status.get("EXP-2026-9999") → null
    Optional.ofNullable(…).orElse("見つからない") → 見つからない

现在的 Java 会用一句话告诉你哪里是 null:“因为 ExpenseRequest.item() 的返回值是 null,无法调用 String.length()”。以前只给行号,光这一点就能大大减少排查时间。

返回“可能没有”的结果时,用 Optional 包起来代替 null,接收方就不容易忘写“没有时”的处理。按受理编号查申请时(第 8 讲)会用到。而一开始就确认传进来的值不是 null,是第 6 讲的输入检查。

九

record、列表、字符串

第 1 讲的 ExpenseRequest 是用 record 写的。装好内容创建后就不能改,是用来搬运值的类型。

# record:按内容比较
    x                              → ExpenseRequest[date=2026-10-26, item=交通費, amount=1280]
    x.equals(y)                    → true
    x == y                         → false
    x.equals(z)  // z 是 1280.00   → false
# 用 List.of 做的列表,之后不能修改
    List.of("交通費", "会議費").add("宿泊費") → UnsupportedOperationException: null
# 字符串不用 ==,用 equals
    "交通費" == new String("交通費") → false
    "交通費".equals(new String("交通費")) → true

record 按内容比较,内容相同 equals 就是 true。不过金额若是 1280 和 1280.00,按 BigDecimal 的 equals 就是 false。== 比的是“是不是同一个东西”,内容相同也是 false。字符串也一样,字符串之间一定要用 equals 比较。用 List.of 做的列表不能修改,往里加会抛异常。

# 字符数和字节数
    "交通費".length()              → 3
    "交通費".getBytes(UTF_8).length → 9
    "🚃".length()                  → 2
    "🚃".codePointCount(…)         → 1
    "🚃".getBytes(UTF_8).length    → 4
# 看起来像数字的字符串
    Integer.parseInt("1,280")      → NumberFormatException: For input string: "1,280"
    new BigDecimal("1280")     → 1280
# 去掉环境变量 LANG 再运行同一个程序
pc$ env -u LANG java Lesson2.java text
    "???".length() = 3
    "???" ? UTF-8 ???? = 9
Wireshark 的界面。第 1 讲记录的第 13 个,展开 PostgreSQL(Type: Bind)的 Parameter values。依次是 Column length: 13、10、9、10,选中第 3 个 Data: e4baa4e9809ae8b2bb。下方状态栏是 Parameter data (pgsql.val.data), 9 byte(s)
重新打开第 1 讲的记录,看项目“交通費”发往数据库时的字节。在 Java 里是 3 个字符的“交通費”,在线上是 UTF-8 的 9 字节(笔者在云端 Linux 上于 2026 年 10 月 26 日截图。窗口上方的标题栏是为了说明而后加的)

“交通費”在 Java 里是 3 个字符,UTF-8 是 9 字节。重新打开第 1 讲的记录,发往数据库的确实是 9 字节。PostgreSQL 的 varchar(100) 按字符数计,但也有按字节计的系统,还可能和页面输入框的字数对不上。表情符号“🚃”在 Java 的 length() 里是 2,作为字符是 1 个。

全角的“1280”被 BigDecimal 直接读成了 1280。很体贴,但输入检查若没考虑到,意想不到的值就会通过(第 6 讲)。最后两行是去掉环境变量 LANG 运行同一程序的结果,日语变成了“?”。服务器出现乱码,先怀疑 LANG 和字符编码的设置。

十

在自己的电脑上确认

想试的事命令
启动 jshell(结束用 /exit)jshell
double 的误差Math.floor(1100 / 1.1)
BigDecimal 的比较new BigDecimal("1280").compareTo(new BigDecimal("1280.00"))
舍入方式new BigDecimal("2.5").setScale(0, RoundingMode.HALF_EVEN)
同一瞬间换个地方看Instant.now().atZone(ZoneId.of("Asia/Shanghai"))
JVM 的时区设置java.time.ZoneId.systemDefault()
指定时区启动jshell -R-Duser.timezone=UTC
字符数和字节数"交通費".getBytes("UTF-8").length

JDK 25 的 jshell 一开始就导入了整个 java.base 模块(可用 /imports 确认),BigDecimal 和 Instant 都不用 import 就能写。

结 — 一页总结与下一讲

  1. 钱不要用 double。含税 1,100 日元的不含税金额会变成 999 日元。
  2. BigDecimal 从字符串创建,用 compareTo 比较。1280 和 1280.00 用 equals 是两回事。
  3. 舍入方式自己选,作为规则集中在一处。除不尽的除法会停下。
  4. 时刻是瞬间+哪里的时钟。作业里的 4 个时刻都是同一瞬间。
  5. 记录的时刻用 Instant 和 timestamptz,日历日期用 LocalDate 和 date。不用服务器的“今天”决定申请日。
  6. 不共用 SimpleDateFormat,换成 java.time。
  7. null 靠 Optional 和输入检查来防。record 按内容比较。“交通費”是 3 个字符、9 字节。

新井准备好了。下一讲是 Spring Boot 吗?

古井还早。下一讲不用 Spring,只用 JDK 接收 HTTP。把第 1 讲里 Tomcat 和 Jackson 替我们做的事,徒手做一遍。

参考

记录来自笔者在云端 Linux(Ubuntu 24.04,内核 6.18)上,用 OpenJDK 25.0.4 的 jshell 和 java,以及第 1 讲的 PostgreSQL 16,于 2026 年 10 月 26 日取得。double 计算不含税金额的误差件数,是对 1 日元到 100,000 日元的所有含税金额,比较用 double 除后舍去的值和用整数计算的值数出来的。SimpleDateFormat 的次数每次运行都不同。表中的数字和输出都是从当时命令的输出读出的实际数值。登场人物均为虚构。

古法后端入门 第2讲 — Java 准备 / 2026.10

← 返回文章列表