我图省事把 Java 枚举的 ordinal 序号存进数据库当状态值、跑了一年风平浪静,直到产品要在枚举中间插一个新状态,我加完一上线,数据库里成千上万条记录的状态全错位了、原本是已支付的变成了已取消,排查很久才反应过来 ordinal 是按声明顺序排的、我一插队后面全跟着挪了位
这是一次让我把 Java 里"枚举的 ordinal()"这件事,从"每个枚举值的稳定编号",重新理解成"它只是声明顺序里的位置、一改顺序就全错位"的事故。我图省事把枚举的 ordinal() 序号存进数据库当状态值,跑了一年风平浪静。直到产品要在枚举中间插一个新状态,我加完一上线,数据库里成千上万条记录的状态全错位了——原本是"已支付"的变成了"已取消"。我排查很久才反应过来:ordinal() 是按声明顺序排的,我一插队,后面全跟着挪了位。这篇就把这次"枚举插值、ordinal 错位、旧数据全乱"的事故,从头到尾复盘一遍。
故障现场:插了个新枚举,旧数据状态全错位
我有个订单状态枚举 OrderStatus { CREATED, PAID, SHIPPED, COMPLETED, CANCELLED }。存数据库时,我图省事,直接存它的 ordinal()——也就是 CREATED=0, PAID=1, SHIPPED=2, COMPLETED=3, CANCELLED=4。读出来时再用 OrderStatus.values()[storedInt] 还原。跑了一年,数据库里全是 0、1、2、3、4 这些数字,一切正常。
直到产品提了个需求:在"已支付"之后、"已发货"之前,加一个"已备货(PACKED)"状态。我自然地把它加在了中间:{ CREATED, PAID, PACKED, SHIPPED, COMPLETED, CANCELLED }。改完测试了下新流程没问题,就上线了。结果灾难发生了:数据库里所有旧订单的状态全乱了——原本存的是 2(本该是 SHIPPED 已发货)的订单,读出来变成了 PACKED(已备货);原本 3(COMPLETED 已完成)变成了 SHIPPED;4(CANCELLED 已取消)变成了 COMPLETED……每个旧状态都整体往后错了一位。客服炸了:大量"已完成"的订单显示成"已发货"、"已取消"的显示成"已完成"。我盯着代码懵了:我只是加了一个新值,怎么把旧数据全搞乱了?直到我想起 ordinal() 的定义,才彻底明白根因——ordinal() 返回的,是这个枚举常量在枚举声明中的位置序号(从 0 开始按声明顺序数)。它不是一个我"赋予"每个枚举的、固定的编号,而是一个由"它在声明列表里排第几"动态算出来的位置。我在 PAID 和 SHIPPED 之间插入了 PACKED,于是从 PACKED 开始,后面所有枚举的声明位置都往后挪了一位:SHIPPED 从原来的第 2 位变成了第 3 位、COMPLETED 从 3 变 4、CANCELLED 从 4 变 5。可数据库里存的是旧的 ordinal 数字(2、3、4),它们没变;而我用 values()[storedInt] 还原时,是按新的声明顺序去取第 storedInt 个枚举——于是旧的 2 取到了现在排第 2 位的 PACKED(而非它当初代表的 SHIPPED)。存进去的"位置序号"和读出来时"该位置上的枚举",因为我改了声明顺序,对不上了。我以为 ordinal 是每个枚举值"自带的、不变的身份证号",可它其实只是"排队时的当前位次"——我往队伍中间插了一个人,后面所有人的位次就全变了,而我数据库里记的还是他们改变之前的旧位次。
// 我的枚举(一年前)
enum OrderStatus { CREATED, PAID, SHIPPED, COMPLETED, CANCELLED }
// ordinal: 0 1 2 3 4
// 存库: 存 status.ordinal(); 读: OrderStatus.values()[storedInt]
// 数据库里: 已发货订单存的是 2, 已完成存 3, 已取消存 4
// 一年后, 在中间插入 PACKED:
enum OrderStatus { CREATED, PAID, PACKED, SHIPPED, COMPLETED, CANCELLED }
// ordinal: 0 1 2 3 4 5
// ↑ 从 PACKED 起, 后面全部 ordinal +1 了!
// 灾难: 旧数据存的 ordinal 数字没变, 但 values()[i] 按新顺序取
OrderStatus.values()[2]; // 旧数据存 2 本是 SHIPPED, 现在取到 PACKED ✗
OrderStatus.values()[3]; // 旧数据存 3 本是 COMPLETED, 现在取到 SHIPPED ✗
OrderStatus.values()[4]; // 旧 4 本是 CANCELLED, 现在取到 COMPLETED ✗
// 根因: ordinal() 是"在声明中的位置序号", 随声明增删/重排而变,
// 它不是你赋予的稳定编号, 而是动态的位次; 拿它持久化 = 把会变的位置当身份
问题被钉死在这个认知错位上:我以为 ordinal() 是每个枚举值"固定不变的、自带的编号(身份证号)",但它其实是这个枚举值"在声明列表里当前排第几"的位置序号——它由声明顺序动态决定,会随枚举的增、删、重新排序而改变。我把这个"会变的位置"存进了数据库当作状态的持久标识;只要枚举的声明顺序一变(比如往中间插一个),同一个 ordinal 数字,对应的就不再是当初那个枚举值了——存进去时的"位置"和读出来时"那个位置上的枚举",在我改了声明顺序后,彻底错位。持久化的标识,必须是稳定不变的;而 ordinal 恰恰是会随代码改动而漂移的,我用它做持久化,等于把数据库里所有历史数据的正确性,押在了"这个枚举的声明顺序永远不变"这个我根本无法保证的假设上。我以为我给每个状态发了一个永久的身份证号,其实我记的只是他们当时站队的位次;队伍一重排,这些位次就对不上人了。
第一件事:想明白 ordinal 是位置序号、随声明变,不能持久化
把这次事故彻底想清楚,关键是理解枚举的 ordinal() 返回的是该常量在枚举声明中的位置(从 0 开始的序号),它由声明顺序决定、随之变化:在中间插入一个值、删除一个值、或调整顺序,都会让其他常量的 ordinal 改变。Java 官方文档明确说:ordinal "设计上主要给 EnumSet、EnumMap 这类内部数据结构用,绝大多数程序员都用不到这个方法"——它不是一个为"持久化、序列化、外部协议"设计的稳定标识。把 ordinal 存进数据库、写进文件、传给外部系统,意味着这些持久化的值的含义,被绑定到了"枚举声明顺序绝不改变"这个极其脆弱的前提上。
所以正确的做法是:给枚举一个显式赋予的、与声明位置无关的、稳定的标识来做持久化——最常见的是给每个枚举常量定义一个固定的 code 值(自己指定的整数或字符串),持久化时存这个 code、读取时按 code 反查,这样无论以后怎么增删、重排枚举,每个枚举的 code 都不变、旧数据永远对得上。如果用字符串持久化,枚举的 name()(常量名)比 ordinal 稳定得多(增删别的值不影响它),但改名会坏、且占空间,折中可用;但绝不要用 ordinal()。新增枚举值时,只要给它分配一个没用过的新 code、并把它加在哪个位置都无所谓。关键认知是:凡是要被持久化、序列化、或跨系统传递的"标识",都必须是稳定不变、与它在代码/声明中的位置无关的——绝不能用"位置/顺序/序号"这种会随代码演化(增删、重排)而漂移的东西当标识。要给每个需要被长期、外部引用的东西,显式分配一个独立于其位置的、永不改变的身份。
// 正解: 给枚举显式定义稳定的 code, 持久化用 code 而非 ordinal
enum OrderStatus {
CREATED(1), PAID(2), SHIPPED(3), COMPLETED(4), CANCELLED(5),
PACKED(6); // ★ 新增值给个【没用过的新 code】, 加哪都行
private final int code;
OrderStatus(int code) { this.code = code; }
public int getCode() { return code; }
// 按 code 反查(建个静态 Map 更快)
private static final Map BY_CODE = new HashMap<>();
static {
for (OrderStatus s : values()) BY_CODE.put(s.code, s);
}
public static OrderStatus fromCode(int code) {
OrderStatus s = BY_CODE.get(code);
if (s == null) throw new IllegalArgumentException("unknown code: " + code);
return s;
}
}
// 存库: status.getCode(); 读: OrderStatus.fromCode(storedInt);
// 现在: PACKED 加在末尾或中间都无所谓, 每个 code 固定, 旧数据永远对得上 ✓
// 反例(别这么做): 用 ordinal 持久化 —— 声明顺序一变旧数据全错位
// store(status.ordinal()); load = OrderStatus.values()[storedInt]; // ✗
// 用字符串持久化时, name() 比 ordinal 稳(增删别的不影响), 但改名会坏:
// store(status.name()); load = OrderStatus.valueOf(storedString);
想通这一层,我才明白自己错在哪:我把 ordinal() 当成了枚举值"固定的身份证号",而它其实是"当前在声明里排第几"的位置序号、会随枚举增删重排而变;我把这个会变的位置存进了数据库当持久标识,于是一旦我往枚举中间插值、改变了声明顺序,所有历史数据的 ordinal 就和它们的真实含义错位了。持久化标识必须稳定不变,而我用了一个绑定在"声明顺序不变"这个脆弱假设上的东西。根治之道,是给枚举显式分配一个与位置无关的、固定的 code,持久化用 code、按 code 反查。不是用"它现在排第几"当标识,而是给每个要被长期引用的东西,一个独立于位置、永不改变的身份。
第二件事:正解——给枚举显式定义稳定 code,持久化用 code 而非 ordinal
找到根因,正解就清晰了:凡是要持久化/序列化/跨系统传递的枚举,给每个常量显式定义一个固定的 code(自己指定、与声明位置无关)、持久化时存 code、读取时按 code 反查;新增枚举值只分配一个没用过的新 code、加在哪个位置都无所谓;绝不用 ordinal()(随声明顺序漂移);用字符串持久化时 name() 比 ordinal 稳但改名会坏,可作折中。
// 错误: 用 ordinal 持久化, 声明顺序一变旧数据全错位
store(status.ordinal()); // ✗
OrderStatus s = OrderStatus.values()[storedInt]; // ✗
// 正解: 显式 code + 按 code 反查(稳定, 与声明位置无关)
public enum OrderStatus {
CREATED(1), PAID(2), SHIPPED(3), COMPLETED(4), CANCELLED(5), PACKED(6);
private final int code;
OrderStatus(int code) { this.code = code; }
public int getCode() { return code; }
private static final Map BY_CODE = new HashMap<>();
static { for (var s : values()) BY_CODE.put(s.code, s); }
public static OrderStatus fromCode(int code) {
var s = BY_CODE.get(code);
if (s == null) throw new IllegalArgumentException("unknown code " + code);
return s;
}
}
// 存: status.getCode(); 读: OrderStatus.fromCode(n); 增删重排都不影响旧数据
// 校验: 加个测试锁死"code 不可变"——防以后有人改了某个 code
// @Test void codesAreStable() {
// assertEquals(2, OrderStatus.PAID.getCode()); // PAID 永远是 2
// assertEquals(3, OrderStatus.SHIPPED.getCode());
// // 任何人改了已发布的 code, 测试就挂, 提醒这会破坏历史数据
// }
// 字符串持久化的折中: name() 比 ordinal 稳, 但改常量名会坏旧数据
// store(status.name()); load = OrderStatus.valueOf(s);
这套做法的精髓,是给每个需要被长期、外部引用的枚举,一个"由我显式赋予、与它在代码里排第几无关、且承诺永不改变"的稳定 code,让持久化的值的含义不再依赖"声明顺序不变"这个脆弱前提。显式 code 把"身份"和"位置"彻底解耦:以后枚举怎么增删、重排,每个 code 对应的枚举都不变、历史数据永远对得上;新增值只要给个新 code。再加一个"code 不可变"的测试,把这个约定钉死,防止以后有人误改已发布的 code。不是用枚举此刻的位置当持久标识,而是给它一个独立于位置、承诺不变的身份。
【枚举要持久化/传给外部, 我现在认死的几条】
1. ordinal() 是"在声明中的位置序号", 随增删/重排而变, 不是稳定编号
2. 它官方设计给 EnumSet/EnumMap 内部用, 绝不该拿来持久化
3. 用 ordinal 持久化: 枚举中间插值/重排, 旧数据全部错位
4. 持久化用显式分配的、与位置无关的固定 code; 按 code 反查
5. 新增枚举值: 给个没用过的新 code, 加在哪个位置都无所谓
6. 加测试锁死"已发布的 code 不可变", 防以后误改坏历史数据
7. 字符串持久化 name() 比 ordinal 稳, 但改常量名会坏, 折中可用
第三件事:其他"用位置/顺序当稳定标识、一变就错位"的同类坑
顺着"用'位置/顺序/序号'当稳定标识、而它会随增删重排漂移、一变就全错位"这条线,我把同类的坑都排查了一遍:
第一个,按列位置取数据库结果。rs.getString(3) 按列序号取,SQL 里 SELECT 的列顺序一改、或表加了列,序号就错位;要按列名取。
第二个,protobuf/序列化按字段顺序而非 tag 号。协议字段靠位置而非唯一 tag/字段号标识,新增字段插在中间,新旧端解析全错位;要给每个字段固定编号。
第三个,CSV/定长格式按列序号解析,源加了一列。按第几列取值,上游在中间插一列,后面所有列都错位;要按表头名。
第四个,用数组下标当持久 ID。把元素在数组里的下标存起来当 ID,数组增删/重排后下标对应的元素就变了;要用元素自身的稳定 ID。
第四件事:ordinal vs name vs 显式 code——一张持久化对照表
我把枚举的三种"持久化标识"摆在一起对比,核心看"什么改动会把它弄坏":
| 方式 | 是什么 | 什么改动会坏 | 能否持久化 |
|---|---|---|---|
| ordinal() | 声明中的位置序号 | 增/删/重排任意值都坏 | 绝不能 |
| name() | 常量名字符串 | 改这个常量的名字才坏 | 折中可用(占空间) |
| 显式 code | 自己赋予的固定值 | 只有手动改它的 code 才坏 | 推荐 |
| values()[i] | 按位置取(配 ordinal) | 同 ordinal, 顺序一变就错 | 绝不能 |
看清这张表,选择就明确了:持久化首选显式 code(只有手动改 code 才会坏,而那是显眼的、可被测试拦住的);name() 是折中(改常量名才坏);ordinal/values()[i] 绝不能用——它们随任何增删重排而漂移。我这次踩坑,正是用 ordinal 持久化、在中间插值后旧数据全错位。把标识从"位置"换成"显式赋予的固定值",是历史数据永远对得上的关键。
第五件事:我曾经对 ordinal 想当然的几个误区
这次事故也把我对 ordinal 的一堆"想当然"照了个底朝天:
| 我以为 | 实际上 |
|---|---|
| ordinal 是每个枚举值固定的编号 | 是它在声明里当前排第几, 随增删重排而变 |
| 只加一个新枚举值不影响旧数据 | 插在中间会让后面所有 ordinal 错位 |
| 旧数据乱了肯定是写错了哪段逻辑 | 是 ordinal 漂移, 旧数字对应到了新枚举 |
| ordinal 存数据库省事又紧凑 | 省一时, 一旦枚举顺序变就毁掉全部历史数据 |
| 枚举顺序我以后不会改 | 需求会逼你改, 别把数据正确性押在这上面 |
这些误区的根子是同一个:我把一个"由位置/顺序动态决定、会随代码演化而漂移"的东西(ordinal),当成了一个"固定不变的身份标识",还拿它去做了最需要稳定性的事——持久化。位置序号天生就是"相对的、会变的";而持久化标识天生就要"绝对的、不变的"。我把这两个性质相反的东西画上了等号,于是当枚举声明顺序这个"位置"一变,所有存下来的历史数据的含义就全乱了。把"位置/顺序"误当成"身份",拿会漂移的东西做需要永恒稳定的标识,是这类历史数据错位的共同根源。
第六件事:持久化枚举、排查"加了个值旧数据全错位"时,我现在的自检习惯
现在每当我把枚举持久化、或排查"加/调了枚举值后旧数据状态全错位",我都会先按这张图问自己:
这张图的精髓,是"加枚举值旧数据错位先看是不是存了 ordinal;ordinal 随声明顺序漂移、持久化必须用显式 code"。设计就持久化枚举给每个常量显式定义固定 code、存 code 按 code 反查、新增值给新 code、加测试锁死 code 不可变、排查就看持久化的是 ordinal 还是 code、是不是声明顺序变了导致 ordinal 漂移。这套习惯,让我从"图省事存 ordinal"变成了"持久化必用与位置无关的稳定 code"——核心始终是:枚举的 ordinal() 返回的是该常量在枚举声明中的位置(从 0 开始的序号),它由声明顺序决定、随之变化:在中间插入一个值、删除一个值、或调整顺序都会让其他常量的 ordinal 改变;Java 官方文档明确说 ordinal 设计上主要给 EnumSet、EnumMap 这类内部数据结构用、绝大多数程序员都用不到这个方法,它不是一个为持久化序列化外部协议设计的稳定标识;把 ordinal 存进数据库、写进文件、传给外部系统意味着这些持久化的值的含义被绑定到了枚举声明顺序绝不改变这个极其脆弱的前提上,一旦往枚举中间插入一个值,从该值起后面所有常量的声明位置都往后挪一位、ordinal 全部 +1,而数据库里存的旧 ordinal 数字没变、用 values()[storedInt] 还原时按新声明顺序去取第 storedInt 个枚举、旧的数字就取到了错误的枚举、历史数据全部错位;正解是给枚举一个显式赋予的、与声明位置无关的、稳定的标识来做持久化——最常见的是给每个枚举常量定义一个固定的 code 值(自己指定的整数或字符串)、持久化时存这个 code、读取时按 code 反查,这样无论以后怎么增删重排枚举每个枚举的 code 都不变、旧数据永远对得上、新增枚举值只要分配一个没用过的新 code、加在哪个位置都无所谓,用字符串持久化时 name() 比 ordinal 稳得多但改名会坏可作折中、绝不要用 ordinal();一句话,凡是要被持久化序列化或跨系统传递的标识都必须是稳定不变、与它在代码/声明中的位置无关的,绝不能用位置/顺序/序号这种会随代码演化(增删重排)而漂移的东西当标识,要给每个需要被长期外部引用的东西显式分配一个独立于其位置的、永不改变的身份。
我立下的几条规矩
这场"枚举插值、ordinal 错位、旧数据全乱"的事故,换来了我持久化枚举时,刻进骨子里的几条铁律:
- ordinal() 是"在声明中的位置序号",随增删/重排而变,不是稳定编号。
- 它官方设计给 EnumSet/EnumMap 内部用,绝不该拿来持久化。
- 用 ordinal 持久化:枚举中间插值/重排,旧数据全部错位。
- 持久化用显式分配的、与位置无关的固定 code;按 code 反查。
- 新增枚举值:给个没用过的新 code,加在哪个位置都无所谓。
- 加测试锁死"已发布的 code 不可变",防以后误改坏历史数据。
- 字符串持久化 name() 比 ordinal 稳,但改常量名会坏,折中可用。
附:我现在持久化枚举的"显式 code + 反查 + 不可变测试"骨架
这是我现在持久化枚举固定套的骨架——把这次踩坑的教训(显式 code、按 code 反查、测试锁死 code 不可变、新增给新 code)固化成一套结构,让"枚举插值旧数据全错位"那种坑再不会埋进系统:
public enum OrderStatus {
// 每个常量显式分配一个固定 code(与声明位置无关、承诺永不改变)
CREATED(1), PAID(2), SHIPPED(3), COMPLETED(4), CANCELLED(5),
PACKED(6); // 新增值: 给一个没用过的新 code, 加在哪个位置都无所谓
private final int code;
OrderStatus(int code) { this.code = code; }
public int getCode() { return code; }
// 按 code 反查(静态 Map, O(1)); 顺带在静态块校验 code 不重复
private static final Map BY_CODE = new HashMap<>();
static {
for (OrderStatus s : values()) {
if (BY_CODE.put(s.code, s) != null) {
throw new IllegalStateException("重复的 code: " + s.code); // 配置错当场炸
}
}
}
public static OrderStatus fromCode(int code) {
OrderStatus s = BY_CODE.get(code);
if (s == null) throw new IllegalArgumentException("unknown OrderStatus code: " + code);
return s;
}
}
// 持久化: 存 status.getCode(); 读 OrderStatus.fromCode(stored);
// 绝不用: status.ordinal() / OrderStatus.values()[stored]
// 关键: 加一个测试, 把"已发布的 code 永不改变"钉死
class OrderStatusTest {
@Test void codesAreFrozen() {
// 显式断言每个已上线的 code —— 谁改了 code 这测试就挂, 提醒会毁历史数据
assertEquals(1, OrderStatus.CREATED.getCode());
assertEquals(2, OrderStatus.PAID.getCode());
assertEquals(3, OrderStatus.SHIPPED.getCode());
assertEquals(4, OrderStatus.COMPLETED.getCode());
assertEquals(5, OrderStatus.CANCELLED.getCode());
}
@Test void noDuplicateCodes() { // 防新增值复用了已有 code
long distinct = Arrays.stream(OrderStatus.values())
.map(OrderStatus::getCode).distinct().count();
assertEquals(OrderStatus.values().length, distinct);
}
}
这套骨架把我这次的教训钉死在了结构里:每个枚举常量显式分配一个与位置无关的固定 code、持久化存 code 按 fromCode 反查、静态块校验 code 不重复、新增值给没用过的新 code加哪都行;再用 codesAreFrozen 测试锁死已发布 code 不可变、noDuplicateCodes 防复用。这样,以后枚举怎么增删、重排、加新值,每个 code 对应的枚举都不变、历史数据永远对得上,而不再是当初那个"用 ordinal 持久化、中间一插值旧数据全错位"的局面。把"分清位置与身份、长期引用用稳定身份而非流动的位置"这个道理,沉淀成持久化枚举的固定骨架,这是我对这次"几万条订单状态错位"最实在的交代——毕竟,要让一个状态在十年后的数据库里还认得出自己,就得给它一个不论队伍怎么重排都属于它的号牌,而不是它当时站在第几个的位次。
写在最后
回头看,这场由"ordinal 持久化"引发的"旧数据全错位"事故,真正教给我的,远不止"改用显式 code"这一个技巧。它让我对"'一个东西在某个序列/集合里排第几'(它的位置、顺序、序号),和 '这个东西是谁'(它的身份、标识),是两个根本不同的概念:位置是相对的、随集合的增删重排而流动的;身份应当是绝对的、不论它在哪儿都属于它自己的;我们常常因为'位置'恰好是个现成的、唯一的数字,就图省事拿它当'身份'来用,却忘了位置会变——一旦集合的构成变了,位置就重新洗牌,而那些'记着旧位置'的引用,就全都指错了人",有了一次刻骨的体会。我栽跟头,是因为我把"一个枚举值此刻排在第几位"(位置)当成了"这个枚举值是谁"(身份),并把这个位置存了下来当作永久的引用——ordinal 恰好是个现成的、从 0 开始的、彼此唯一的整数,看起来就像一个天然的编号,我便顺手拿它当了状态的身份证号存进库;我没意识到,这个数字的含义完全取决于"它在声明队列里的位次",而队列的构成是会变的:我往中间插了一个新成员,从它往后每个人的位次都顺移了一位;可数据库里那些旧记录,记的还是成员们改变之前的旧位次——于是同一个数字,在新队列里指向的已是另一个人,几万条历史数据就这样集体认错了主。这让我领悟到一个关于"位置与身份"的深刻认知:位置(排第几、在哪个槽、什么顺序)和身份(它本质上是谁)是必须分清的两回事:位置是相对于一个会变化的集合而言的、是流动的、会随集合的增删重排而重新分配;身份则应当是这个个体自带的、绝对的、不依赖它当前在哪的;任何需要"长期保存、跨时间或跨系统引用"一个个体的场景,引用的都必须是它的身份而非位置——因为保存下来的引用要在未来依然有效,而未来那个集合很可能已经变了、位置已经重新洗过牌,只有不随集合变化的身份才始终指向同一个个体;而最大的诱惑,恰恰是位置常常表现为一个现成的、唯一的、紧凑的数字(序号、下标、位次),让人误以为它就是个稳定的 ID——但它的唯一性是"在当前这个快照里唯一",不是"永远属于它"。这给了我一种看待"一切'给某物一个要被长期引用的标识'之事"时的清醒:每当我要保存一个对某个体的引用、让它在未来或别处仍能指对那个个体时,要追问"我存的这个东西,是它的'位置(在某集合里排第几)',还是它的'身份(它自己永久的标识)'?如果是位置,那个集合以后会增删重排吗?一旦重排,我这个引用还指得对吗"——长期引用一律用与位置无关的、显式赋予的稳定身份,绝不用会随集合变化而漂移的位置/序号;"分清位置与身份、长期引用用稳定身份而非流动的位置",是持久化枚举、也是设计一切持久标识的关键。认清 ordinal 是位置序号会随声明变、持久化必须用显式 code、位置不是身份——这,是我用一次"枚举插值导致几万条旧数据状态全错位"的事故,换来的、关于 Java、也关于如何分清位置与身份的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次想把枚举的 ordinal() 存进数据库时,先停一秒想想"以后这枚举要是中间插个值呢?要不要给它一个固定的 code?",那我对着那几万条"已完成显示成已发货"的错位订单排查的大半天,就值了。
—— 别看了 · 2026