
【Java实战】实体设计与JSON持久化手写一个不丢数据的数据层原子落盘/损坏兜底/ID种子全拆解系列《研发效能智能助手》实战专栏 · 第 2 篇上篇回顾《从零写一个Java代码片段管理器纯JDK17、四层架构、30个单元测试》 点这里看第 1 篇配套源码完整可运行 Maven 工程JDK 17文末附下载一、先讲一个真实翻车现场我第一版代码片段管理器数据存得很天真// 第一版直接把整个列表写进文件Files.writeString(path,jsonString);用了一周翻车了——程序写文件写到一半断电snippets.json 直接变成半个 JSON几周的片段全废了。更窝火的是第二个坑我改了代码重新启动新增片段居然报错——ID 冲突。旧数据里最大 ID 是 3新程序从 1 开始发号第 4 条没保存却把 ID1 的旧数据覆盖了。这两个坑就是这篇文章要讲的三件事实体怎么设计才不会被改坏、才能和 Jackson 好好配合怎么写文件才能保证任何时刻磁盘上都有一个完整可用的数据文件原子落盘重启后 ID 怎么续才能保证永不冲突ID 种子延续。顺带把数据文件损坏了程序不崩溃的兜底也一起做了。二、实体设计一个实体类藏着三个面试考点先看CodeSnippet实体完整代码在项目里这里说三个最关键的设计决策。2.1 无参构造 工厂方法两种创建方式各司其职publicclassCodeSnippet{privateLongid;privateStringtitle;privateLanguagelanguage;privateListStringtags;privateStringcontent;privatebooleanfavorite;privateLocalDateTimecreatedAt;privateLocalDateTimeupdatedAt;/** * 无参构造仅供 Jackson 反序列化使用。 * 业务代码请使用 create() 工厂方法。 */publicCodeSnippet(){}/** * 工厂方法自动完成时间初始化、收藏默认值、标签防御性拷贝。 */publicstaticCodeSnippetcreate(Longid,Stringtitle,Languagelanguage,ListStringtags,Stringcontent){CodeSnippetsnippetnewCodeSnippet();snippet.idid;snippet.titletitle;snippet.languagelanguage;snippet.tagstagsnull?newArrayList():newArrayList(tags);snippet.contentcontent;snippet.favoritefalse;LocalDateTimenowLocalDateTime.now();snippet.createdAtnow;snippet.updatedAtnow;returnsnippet;}}为什么要有无参构造Jackson 反序列化把 JSON 转对象默认调用无参构造。没有它程序一读文件就报错。为什么业务代码不直接用无参构造因为create()帮你把三件容易忘的事一次性做对创建时间、更新时间初始化成当前时间收藏默认 false标签做拷贝隔离。你写业务的时候根本不需要记得哦我还要 set 时间。这就是大厂说的把容易出错的初始化逻辑收进工厂方法调用方想错都错不了。2.2 防御性拷贝标签列表为什么不能直接返回看这两个方法publicListStringgetTags(){returntagsnull?Collections.emptyList():Collections.unmodifiableList(tags);}publicvoidsetTags(ListStringtags){this.tagstagsnull?newArrayList():newArrayList(tags);}getTags()返回的是不可修改视图unmodifiableList——外部拿到手只能读调用add()会抛异常。而setTags()是拷贝一份传进来的集合——外部传进来的 List 之后再怎么改也影响不到实体内部的数据。一句话进来拷贝出去只读。这是面试官最爱问的防御性拷贝也是团队代码里最常见的隐性 bug 源头——别人拿到的你的集合改着改着就把你的内部状态改坏了。2.3 时间用 LocalDateTime不用 DateprivateLocalDateTimecreatedAt;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 的四行配置每一行都有讲究ObjectMappermappernewObjectMapper();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. 先写临时文件PathtmpdataFile.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;}DataFiledataOBJECT_MAPPER.readValue(dataFile.toFile(),DataFile.class);snippets.clear();if(data.getSnippets()!null){snippets.addAll(data.getSnippets());}longmaxIdsnippets.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 解析失败程序不是崩溃而是把损坏文件改名备份成snippets.json.bak-20260930103045带时间戳事后还能排查内存清空、重建空库、落盘控制台打一条警告程序正常启动。privatevoidbackupCorruptedFile(){try{StringtimestampLocalDateTime.now().format(DateTimeFormatter.ofPattern(yyyyMMddHHmmss));PathbackupdataFile.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 游标原子类型保证线程安全 */privatestaticfinalAtomicLongCURRENTnewAtomicLong(0);/** * 初始化种子将游标提升到不小于已有最大 ID 的值。 * 用 accumulateAndGet 取最大值避免重复初始化时游标回退导致 ID 冲突。 */publicstaticvoidinitSeed(longmaxId){CURRENT.accumulateAndGet(maxId,Math::max);}/** 生成下一个全局唯一 ID从种子 1 开始递增 */publicstaticlongnextId(){returnCURRENT.incrementAndGet();}}关键在initSeed(maxId)里的accumulateAndGet(maxId, Math::max)启动加载数据后把历史最大 ID 传进来比如 3Math::max保证游标只升不降——即使被调用两次、传入更小的值也不会回退之后nextId()就从 4 开始发号永不和旧数据冲突。选型说明单进程单用户的本地工具递增 ID 足够且可读性好没必要上雪花算法。要是以后升级成多实例部署把IdGenerator实现换掉就行——这就是把变化关进小笼子的又一个例子。七、这些设计在真实项目里怎么验证光说不练假把式上面这些设计项目里都有对应的单元测试守护着30 个测试全绿// FileSnippetRepositoryTest 里的关键用例TestDisplayName(损坏兜底手动写坏 JSON 后启动不崩溃自动备份并重建空库)voidload_corruptedFile_backsUpAndRebuilds(){// 往数据文件里写入非法 JSONFiles.writeString(tempDir.resolve(data).resolve(snippets.json),{not-json);// 重建仓储 模拟重启FileSnippetRepositoryreponewFileSnippetRepository(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 智能体完整平台。关注专栏跟着我一步步从零实战到就业。如果这篇对你有帮助点赞 收藏 关注是我持续更新的最大动力。有任何问题欢迎评论区留言我看到都会回复。