☰
实体设计与JSON持久化:手写一个不丢数据的数据层,原子落盘/损坏兜底/ID种子全拆解
2026/10/2 21:00:46 网站建设 项目流程

【Java实战】实体设计与JSON持久化:手写一个"不丢数据"的数据层,原子落盘/损坏兜底/ID种子全拆解

系列:《研发效能智能助手》实战专栏 · 第 2 篇
上篇回顾:《从零写一个Java代码片段管理器:纯JDK17、四层架构、30个单元测试》👉 点这里看第 1 篇
配套源码:完整可运行 Maven 工程(JDK 17),文末附下载


一、先讲一个真实翻车现场

我第一版代码片段管理器,数据存得很"天真":

// 第一版:直接把整个列表写进文件Files.writeString(path,jsonString);

用了一周,翻车了——程序写文件写到一半断电,snippets.json 直接变成半个 JSON,几周的片段全废了。

更窝火的是第二个坑:我改了代码重新启动,新增片段居然报错——ID 冲突。旧数据里最大 ID 是 3,新程序从 1 开始发号,第 4 条没保存,却把 ID=1 的旧数据覆盖了。

这两个坑,就是这篇文章要讲的三件事:

  1. 实体怎么设计,才不会被改坏、才能和 Jackson 好好配合;
  2. 怎么写文件,才能保证"任何时刻磁盘上都有一个完整可用的数据文件"(原子落盘);
  3. 重启后 ID 怎么续,才能保证永不冲突(ID 种子延续)。

顺带把"数据文件损坏了程序不崩溃"的兜底也一起做了。


二、实体设计:一个实体类,藏着三个面试考点

先看CodeSnippet实体,完整代码在项目里,这里说三个最关键的设计决策。

2.1 无参构造 + 工厂方法:两种创建方式,各司其职

publicclassCodeSnippet{privateLongid;privateStringtitle;privateLanguagelanguage;privateList<String>tags;privateStringcontent;privatebooleanfavorite;privateLocalDateTimecreatedAt;privateLocalDateTimeupdatedAt;/** * 无参构造:仅供 Jackson 反序列化使用。 * 业务代码请使用 create() 工厂方法。 */publicCodeSnippet(){}/** * 工厂方法:自动完成时间初始化、收藏默认值、标签防御性拷贝。 */publicstaticCodeSnippetcreate(Longid,Stringtitle,Languagelanguage,List<String>tags,Stringcontent){CodeSnippetsnippet=newCodeSnippet();snippet.id=id;snippet.title=title;snippet.language=language;snippet.tags=tags==null?newArrayList<>():newArrayList<>(tags);snippet.content=content;snippet.favorite=false;LocalDateTimenow=LocalDateTime.now();snippet.createdAt=now;snippet.updatedAt=now;returnsnippet;}}

为什么要有无参构造?Jackson 反序列化(把 JSON 转对象)默认调用无参构造。没有它,程序一读文件就报错。

为什么业务代码不直接用无参构造?因为create()帮你把三件容易忘的事一次性做对:创建时间、更新时间初始化成当前时间,收藏默认 false,标签做拷贝隔离。你写业务的时候根本不需要记得"哦我还要 set 时间"。

这就是大厂说的:把容易出错的初始化逻辑收进工厂方法,调用方想错都错不了。

2.2 防御性拷贝:标签列表为什么不能直接返回

看这两个方法:

publicList<String>getTags(){returntags==null?Collections.emptyList():Collections.unmodifiableList(tags);}publicvoidsetTags(List<String>tags){this.tags=tags==null?newArrayList<>():newArrayList<>(tags);}

getTags()返回的是不可修改视图(unmodifiableList)——外部拿到手只能读,调用add()会抛异常。而setTags()是拷贝一份传进来的集合——外部传进来的 List 之后再怎么改,也影响不到实体内部的数据。

一句话:进来拷贝,出去只读。这是面试官最爱问的"防御性拷贝",也是团队代码里最常见的隐性 bug 源头——别人拿到的"你的集合",改着改着就把你的内部状态改坏了。

2.3 时间用 LocalDateTime,不用 Date

privateLocalDateTimecreatedAt;privateLocalDateTimeupdatedAt;

java.util.Date是上世纪的设计(可变、线程不安全、日期格式化全靠 SimpleDateFormat 那个坑货)。企业代码现在统一java.time.LocalDateTime。后面 JSON 序列化只需要注册一个模块,时间就能以人类可读的 ISO 格式落盘。

为什么不用 Lombok?这个项目刻意手写 getter/setter。用@Data一键生成确实爽,但你永远不知道"不可变视图""防御性拷贝"这些设计是怎么写出来的。手写一遍,你就懂了——Lombok 帮你省的时间,面试时会加倍还回去。


三、JSON 持久化:两个容易被忽略的细节

3.1 根结构用对象包裹,不直接存数组

{"snippets":[{"id":1,"title":"字符串反转","...":"..."}

}

为什么不直接存[{...},{...}]?因为未来要加数据版本号、最后修改时间这类元信息,用对象包裹,加字段不破坏结构;直接存数组,加元信息就得改格式、写兼容逻辑。数据结构演进,要给自己留后路。

3.2 ObjectMapper 的四行配置,每一行都有讲究

ObjectMappermapper=newObjectMapper();mapper.registerModule(newJavaTimeModule());// 支持 LocalDateTimemapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);// 时间输出 ISO 字符串mapper.enable(SerializationFeature.INDENT_OUTPUT);// JSON 美化,方便人看mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);// 忽略未知字段,兼容演进

重点说两个:

  • WRITE_DATES_AS_TIMESTAMPS必须关掉:Jackson 默认把LocalDateTime序列化成[2026,9,30,10,30,0]这种数字数组,人没法读,别家程序也不好解析。关掉后输出"2026-09-30T10:30:00",一眼看懂。
  • FAIL_ON_UNKNOWN_PROPERTIES必须关掉:以后你的实体加了新字段、删了旧字段,老数据文件读进来不会直接报错。这是向后兼容的最低成本做法。

四、原子落盘:为什么先写 .tmp 再改名

这是本文最硬核的一段,也是大厂面试官真正会追问的地方。

4.1 直接写正式文件的致命问题

Files.writeString(path, jsonString)看起来没问题,但底层是"打开文件 → 写入内容 → 关闭"。如果写到一半断电/崩溃,磁盘上就是一个残缺文件——前半截是新的,后半截是旧的,JSON 解析直接失败,数据全毁。

4.2 原子替换的正确姿势

privatevoidwriteToFile(){try{Files.createDirectories(dataFile.getParent());// 1. 先写临时文件Pathtmp=dataFile.resolveSibling(DATA_FILE_NAME+".tmp");OBJECT_MAPPER.writeValue(tmp.toFile(),newDataFile(snippets));// 2. 再原子替换正式文件try{Files.move(tmp,dataFile,StandardCopyOption.REPLACE_EXISTING,StandardCopyOption.ATOMIC_MOVE);}catch(IOExceptionatomicUnsupported){// 个别文件系统不支持原子移动,退化为普通替换Files.move(tmp,dataFile,StandardCopyOption.REPLACE_EXISTING);}}catch(IOExceptione){thrownewSnippetException("数据保存失败,请检查磁盘空间或权限。",e);}}

核心思路只有一句话:先写完整,再替换。

  • 第一步,把完整数据写进snippets.json.tmp——写一半崩溃了也没关系,正式文件还是旧的完整的;
  • 第二步,Files.move(ATOMIC_MOVE)把临时文件原子性地变成正式文件——这个操作在文件系统层面要么完全成功、要么完全不发生,不存在"写一半"的状态;
  • 万一某个文件系统不支持原子移动(会抛异常),退化成普通替换,仍然比直接覆盖安全。

任何时候看磁盘,要么是旧版本完整文件,要么是新版本完整文件,永远不存在一个残缺的中间态。这就是"原子落盘"的全部含义。


五、损坏兜底:数据坏了,程序也不能崩

光防"写一半"还不够,现实世界还会遇到:手动改坏文件、磁盘坏道、旧版本程序写出不兼容格式……所以启动加载时要做一层兜底:

privatevoidload(){try{Files.createDirectories(dataFile.getParent());if(!Files.exists(dataFile)){writeToFile();// 首次运行:创建空库return;}DataFiledata=OBJECT_MAPPER.readValue(dataFile.toFile(),DataFile.class);snippets.clear();if(data.getSnippets()!=null){snippets.addAll(data.getSnippets());}longmaxId=snippets.stream().mapToLong(s->s.getId()==null?0L:s.getId()).max().orElse(0L);IdGenerator.initSeed(maxId);// 用历史最大 ID 初始化种子}catch(IOExceptione){// 数据文件损坏:备份 + 重建空库,程序不崩溃backupCorruptedFile();snippets.clear();writeToFile();System.err.println("[警告] 数据文件损坏,已自动备份并重建空库。");}}

重点看catch (IOException e)分支——JSON 解析失败,程序不是崩溃,而是:

  1. 把损坏文件改名备份成snippets.json.bak-20260930103045(带时间戳,事后还能排查);
  2. 内存清空、重建空库、落盘;
  3. 控制台打一条警告,程序正常启动。
privatevoidbackupCorruptedFile(){try{Stringtimestamp=LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));Pathbackup=dataFile.resolveSibling(DATA_FILE_NAME+".bak-"+timestamp);Files.move(dataFile,backup,StandardCopyOption.REPLACE_EXISTING);}catch(IOExceptione){System.err.println("[警告] 损坏数据文件备份失败,将直接重建空库。");}}

为什么备份不直接删?万一数据还能抢救(比如半截 JSON 里手动抠出几条),备份文件是最后的救命稻草。删了就真没了。这是数据工程的基本素养:先保全,再重建。


六、ID 种子延续:重启后怎么保证 ID 不冲突

回到开头那个翻车现场——重启后新 ID 从 1 开始,把旧数据覆盖了。正确做法是 ID 生成器记住"发到哪了":

publicfinalclassIdGenerator{/** 当前 ID 游标,原子类型保证线程安全 */privatestaticfinalAtomicLongCURRENT=newAtomicLong(0);/** * 初始化种子:将游标提升到不小于已有最大 ID 的值。 * 用 accumulateAndGet 取最大值,避免重复初始化时游标回退导致 ID 冲突。 */publicstaticvoidinitSeed(longmaxId){CURRENT.accumulateAndGet(maxId,Math::max);}/** 生成下一个全局唯一 ID:从种子 + 1 开始递增 */publicstaticlongnextId(){returnCURRENT.incrementAndGet();}}

关键在initSeed(maxId)里的accumulateAndGet(maxId, Math::max):

  • 启动加载数据后,把历史最大 ID 传进来(比如 3);
  • Math::max保证游标只升不降——即使被调用两次、传入更小的值,也不会回退;
  • 之后nextId()就从 4 开始发号,永不和旧数据冲突。

选型说明:单进程单用户的本地工具,递增 ID 足够且可读性好,没必要上雪花算法。要是以后升级成多实例部署,把IdGenerator实现换掉就行——这就是"把变化关进小笼子"的又一个例子。


七、这些设计在真实项目里怎么验证

光说不练假把式,上面这些设计,项目里都有对应的单元测试守护着(30 个测试全绿):

// FileSnippetRepositoryTest 里的关键用例@Test@DisplayName("损坏兜底:手动写坏 JSON 后启动不崩溃,自动备份并重建空库")voidload_corruptedFile_backsUpAndRebuilds(){// 往数据文件里写入非法 JSONFiles.writeString(tempDir.resolve("data").resolve("snippets.json"),"{not-json");// 重建仓储 = 模拟重启FileSnippetRepositoryrepo=newFileSnippetRepository(tempDir);// 断言:启动成功,库里是空列表assertTrue(repo.findAll().isEmpty());// 断言:备份文件存在assertTrue(Files.exists(tempDir.resolve("data").resolve("snippets.json.bak-00000000000000"))||Files.list(tempDir.resolve("data")).anyMatch(p->p.toString().contains(".bak-")));}

测试策略是每个测试用独立的@TempDir临时目录,测试之间互不污染——企业级测试的基本素养,下次单独写一篇展开。


八、源码在哪拿

完整源码 + 开发文档 + 30 个单元测试 + 运行演示截图,都在下面这个包里:

👉 Java代码片段管理器-完整源码+开发文档下载

包里包含:

  • 完整 Maven 工程源码(UI / Service / Repository / 存储 四层)
  • 30 个 JUnit 单元测试(全绿,含损坏兜底、ID 种子等关键用例)
  • 开发文档(含设计思路、接口说明、编码规范)
  • README 使用说明 + 运行演示截图

适合谁:看完第 1 篇想继续深入的朋友;面试被问"数据怎么保证不丢"答不上来,想补这块硬知识的人。


九、下期预告

  • 第 3 篇:《面向接口 + 单元测试:大厂工程师怎么写代码》——仓储接口怎么设计、30 个测试怎么规划,以及那个测试抓出的"别名 bug"到底是怎么回事
  • 第 4 篇:《完整源码 + 面试考点总结》——高频面试题逐个击破

本系列共 9 个项目,从命令行工具一路进阶到"Java + Python 智能体完整平台"。关注专栏,跟着我一步步从零实战到就业。


如果这篇对你有帮助,点赞 + 收藏 + 关注,是我持续更新的最大动力。
有任何问题欢迎评论区留言,我看到都会回复。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询