从零实现内存版数据分支:oGMemory记忆系统核心机制解析

📅 2026/8/27 4:45:11
从零实现内存版数据分支:oGMemory记忆系统核心机制解析
这一期继续拆 oGMemory 记忆系统。前面我们已经聊过对象图与记忆系统的基本概念这一篇作为第二期的分集 1重点落在两块oGMemory 的存储结构到底是什么样以及数据分支Data Branch在整个记忆系统里扮演什么角色。理解数据分支不能只停留在“能读能写”层面。真正需要搞清楚的是当多个业务场景同时往同一套记忆系统写入时如何避免互相覆盖当我们在测试新逻辑时如何在不破坏线上主数据的前提下创建一套独立的数据环境当数据出现了误写又该如何快速回退。这些都属于数据分支要解决的问题。这篇文章会先解释 oGMemory 记忆系统的设计意图然后从零实现一套支持数据分支的内存版记忆系统包含分支创建、切换、读写隔离、合并和删除标记等核心操作。全文会给出完整 Java 代码你可以直接复制到本地运行也可以参考思路改造到自己的项目里。1. 什么是 oGMemory 记忆系统1.1 从“缓存”到“记忆系统”很多开发者对缓存的概念已经很熟悉Map、Redis、Caffeine本质都是 key-value 存取。但记忆系统和传统缓存有一个明显区别记忆系统不只存储数据还存储数据之间的关系、数据的来源、数据的版本甚至数据从哪个分支产生。用通俗的话来说缓存解决的是“读得快不快”的问题记忆系统解决的是“记不记得住、记的是哪一份”的问题。oGMemory 的名字里有一个 “G”可以理解为 Graph图也可以理解为 Global全局。它强调的不是单纯把数据塞到内存而是维护一张有结构的对象图。每一个业务对象都可以作为图中的一个节点节点和节点之间可以有引用关系。这种结构天然适合表达配置中心、用户状态、应用元数据、运行时动态配置等场景。1.2 为什么需要“数据分支”传统缓存只有一个命名空间每次写入都是覆盖式写入。问题在于项目上线时我们经常需要在内存中准备一套“灰度数据”或“测试数据”再切到线上游戏业务中需要保存多个玩家存档配置中心里需要先修改配置并验证再发布到所有客户端。如果只有一个数据空间这些场景都很难处理。数据分支就是解决这个问题的思路从主数据上分出一条独立的数据线这条线上的修改不会影响主数据验证完成后再把结果合并回去。1.3 与普通 Map 的区别普通 Map 其实已经实现了“数据存储”这一层但缺少以下能力能力普通 MapoGMemory 记忆系统基本读写支持支持父子数据隔离不支持支持多分支共存不支持支持分支合并不支持支持删除标记不支持支持数据来源追踪不支持支持所以oGMemory 的更准确说法是一套“带版本和分支语义的内存对象图系统”。2. 数据分支的核心模型2.1 主分支与子分支数据分支的模型并不复杂可以类比 Gitmain 是主分支保存全量稳定数据从 main 上可以创建 feature-xxx 子分支子分支初始时可以看到父分支的数据子分支写入的新数据只存在于子分支子分支验证完成后可以合并回 main。父分支和子分支之间是单向关系。子分支可以有多个子分支还可以继续创建子分支形成一棵分支树。2.2 分支数据的读写规则在分支模型中写入和读取的规则需要分清楚写入操作只写入当前分支不会影响父分支读取操作先查当前分支如果当前分支没有再向父分支查询删除操作只能理解为“当前分支中删除”更专业的做法是写入一个删除标记。这里有一个关键点读取是穿透的。子分支能读到父分支的数据但子分支不会把自己的数据反向写到父分支。只有显式执行合并操作时子分支的数据才会到达父分支。2.3 数据分支的核心操作一个完整的数据分支系统应该支持以下操作操作说明createBranch创建分支switchBranch切换当前分支get读取数据put写入数据remove删除数据写入删除标记merge合并子分支到目标分支dropBranch丢弃分支这几种操作组合起来就能覆盖绝大多数业务场景。3. 数据分支能解决哪些业务问题3.1 配置中心灰度发布配置中心是数据分支最典型的应用场景。线上有一份稳定配置运营人员想临时调整某个开关又怕影响到所有用户。这时候可以在配置中心里创建一个灰度分支写入新的配置值让部分流量读取该分支。验证没问题后再把灰度分支合并回主分支完成发布。3.2 游戏存档与剧情分支游戏业务中玩家的选择会导致不同的剧情走向。使用数据分支可以在主角做出关键选择时创建一个分支后续在两个分支上分别推进不同剧情。玩家如果对当前分支不满意可以直接切回选择前的分支再走另一条路线。3.3 权限与租户数据隔离在 SaaS 系统中不同租户可能需要看到不同的业务配置。数据分支可以为每个租户创建独立分支租户读取时自动落到自己的分支上默认数据从主分支继承。这样既保证了基础配置的统一也支持租户自定义覆盖。3.4 系统升级与回滚上线新功能时通常需要同时准备一套新数据结构和一套旧数据结构。通过数据分支可以把新数据写入 feature 分支主分支保持旧数据。如果新逻辑出现问题只需要丢弃 feature 分支主分支的数据完全不受影响。4. 从零设计一个记忆系统4.1 环境准备为了让示例尽量简单本文使用 Java 实现不依赖任何第三方框架。运行环境如下JDK 8 及以上Maven 或 Gradle 可选本文直接使用普通 Java 项目IDE 使用 IntelliJ IDEA 或 Eclipse 均可。如果你使用 Maven项目结构建议如下ogmemory-demo ├── src │ └── main │ └── java │ └── com │ └── example │ └── ogmemory │ ├── MemoryStore.java │ ├── Branch.java │ └── DemoRunner.java └── pom.xml4.2 类设计我们一共设计三个核心类MemoryStore 负责管理所有分支维护当前分支指针Branch 代表一个数据分支内部持有父分支引用和独立数据区DemoRunner 用来演示完整流程。在设计内部数据结构时我们使用一个 Map 来保存当前分支的数据。数据值统一使用 Object方便演示不同类型的对象。4.3 核心数据结构先定义 MemoryStore 的字段import java.util.HashMap; import java.util.Map; public class MemoryStore { private final MapString, Branch branches new HashMap(); private Branch currentBranch; public MemoryStore() { Branch main new Branch(main, null); branches.put(main, main); currentBranch main; } // get, put, createBranch 等方法后续补充 }这里把 main 分支作为系统启动时的默认分支所有数据最初都写到 main 上。4.4 基础的读取和写入接下来在 Branch 类中完成 get 和 put 的基本逻辑。先定义字段import java.util.HashMap; import java.util.Map; public class Branch { private final String name; private final Branch parent; private final MapString, Object data new HashMap(); public Branch(String name, Branch parent) { this.name name; this.parent parent; } public String getName() { return name; } public Branch getParent() { return parent; } public MapString, Object getData() { return data; } public Object get(String key) { if (data.containsKey(key)) { return data.get(key); } if (parent ! null) { return parent.get(key); } return null; } public void put(String key, Object value) { data.put(key, value); } }get 方法是分支模型最重要的一步先查当前分支当前分支没有就向上递归查询父分支。这是实现数据继承的关键。5. 数据分支的完整实现5.1 分支管理入口现在把分支管理和切换逻辑补充到 MemoryStore 中。系统需要维护一份分支注册表同时记住当前操作分支。public Branch createBranch(String branchName) { if (branches.containsKey(branchName)) { throw new IllegalArgumentException(branch already exists: branchName); } Branch branch new Branch(branchName, currentBranch); branches.put(branchName, branch); return branch; } public void switchTo(String branchName) { Branch branch branches.get(branchName); if (branch null) { throw new IllegalArgumentException(branch not found: branchName); } this.currentBranch branch; } public Branch getCurrentBranch() { return currentBranch; } public void put(String key, Object value) { currentBranch.put(key, value); } public Object get(String key) { return currentBranch.get(key); }创建分支时当前分支作为新分支的父分支这样新分支可以继承当前分支的所有数据。如果需要创建基于 main 的分支可以先把当前分支切到 main再创建新分支。5.2 读取隔离验证我们写一段代码验证父子分支之间的读取关系public class DemoRunner { public static void main(String[] args) { MemoryStore store new MemoryStore(); store.put(user.name, zhangsan); store.put(app.theme, light); String branchName feature-v2; store.createBranch(branchName); store.switchTo(branchName); store.put(user.name, lisi); store.put(app.theme, dark); System.out.println(feature 分支 user.name store.get(user.name)); System.out.println(feature 分支 app.theme store.get(app.theme)); store.switchTo(main); System.out.println(main 分支 user.name store.get(user.name)); System.out.println(main 分支 app.theme store.get(app.theme)); } }预期输出feature 分支 user.name lisi feature 分支 app.theme dark main 分支 user.name zhangsan main 分支 app.theme light从输出可以看出feature 分支修改数据后main 分支没有受到影响。这表明读写隔离已经生效。5.3 删除标记设计细心的读者会发现前面写的 remove 方法有一个隐患。如果子分支想删除父分支中的一个 key直接在子分支中移除 key 并不能屏蔽父分支的数据。因为读取时会回溯到父分支父分支的数据依然会被读出来。解决方式是引入删除标记。删除标记没有具体值只代表“这个 key 在当前分支中被删除”。读取时如果遇到删除标记就直接返回 null。修改 Branch 类public static final Object TOMBSTONE new Object(); public Object get(String key) { if (data.containsKey(key)) { Object value data.get(key); return value TOMBSTONE ? null : value; } if (parent ! null) { return parent.get(key); } return null; } public void remove(String key) { data.put(key, TOMBSTONE); }这样一来子分支删除父分支的数据时虽然父分支数据仍然存在但子分支读取的结果已经是 null。5.4 分支合并分支合并是数据分支系统最重要的操作。合并的规则是把子分支中所有写入的数据按 key 覆盖到目标分支。public void merge(String sourceBranchName, String targetBranchName) { Branch source branches.get(sourceBranchName); Branch target branches.get(targetBranchName); if (source null || target null) { throw new IllegalArgumentException(branch not found); } for (Map.EntryString, Object entry : source.getData().entrySet()) { target.getData().put(entry.getKey(), entry.getValue()); } }这里需要注意合并操作会把删除标记也一并合并过去。这样子分支中删除的数据在合并后也会从父分支中消失。这是符合业务直觉的。5.5 完整演示流程下面给出一个比较完整的演示覆盖创建、切换、读写隔离、删除和合并public class DemoRunner { public static void main(String[] args) { MemoryStore store new MemoryStore(); store.put(config.timeout, 1000); store.put(config.retry, 3); // 创建分支并切换到分支 store.createBranch(test-branch); store.switchTo(test-branch); // 覆盖配置 store.put(config.timeout, 2000); // 删除一个 key store.remove(config.retry); // 在分支上读取 System.out.println(test-branch config.timeout store.get(config.timeout)); System.out.println(test-branch config.retry store.get(config.retry)); // 回到 mainmain 不受影响 store.switchTo(main); System.out.println(main config.timeout store.get(config.timeout)); System.out.println(main config.retry store.get(config.retry)); // 合并 test-branch 到 main store.merge(test-branch, main); System.out.println(合并后 main config.timeout store.get(config.timeout)); System.out.println(合并后 main config.retry store.get(config.retry)); } }预期输出test-branch config.timeout 2000 test-branch config.retry null main config.timeout 1000 main config.retry 3 合并后 main config.timeout 2000 合并后 main config.retry null这个流程模拟了一个很常见的发布场景先创建测试分支修改配置、删除不用的配置验证通过后合并回主分支。5.6 分支落盘与序列化考虑上面的实现都是纯内存版本重启后数据全部丢失。对于学习来说没有问题但如果要在真实项目中使用必须考虑持久化。最简单的方案是把分支数据序列化为 JSON 后写入本地文件。序列化时需要注意删除标记 TOMBSTONE 不能被序列化成普通对象建议用固定字符串表示例如{key: config.retry, value: __TOMBSTONE__}反序列化时遇到该标记再转换为内存中的 TOMBSTONE 对象。6. 常见问题与排查思路6.1 分支读取不到父分支数据问题现象常见原因解决思路子分支读取不到已经写入父分支的数据父分支没有设置或者创建子分支时父指针传错检查 Branch 构造函数中 parent 参数是否为 null确认创建逻辑创建分支时一定要使用currentBranch作为父分支否则新分支会变成一个孤儿分支。实际排查可以先打印分支名字和 parent 信息。6.2 删除一个 key 后父分支还能读到问题现象常见原因解决思路remove 后 get 仍然返回父分支数据没有使用删除标记直接执行 data.remove(key)改成写入 TOMBSTONE 标记这是新手最容易犯的错误。子分支删除 key 时不能只从当前 data 中移除因为 get 会回溯父分支。6.3 合并后数据不一致问题现象常见原因解决思路合并后某些旧值没有覆盖合并时只读 source.getData()忽略了父分支数据合并应基于 source.getData()因为只有 source 自身的数据才代表变更集需要理解source.getData()只保存 source 分支自己写入的数据不保存父分支的数据。这个 Map 正好是这一次分支的“变更集”。合并时把变更集应用到目标分支即可。6.4 并发写入导致数据覆盖问题现象常见原因解决思路多个线程同时 put 同一个 key没有加锁对 MemoryStore 的 put、merge 方法加 synchronized或使用 ConcurrentHashMap真实项目中记忆系统往往是多线程访问的。上述示例为了保持简单没有处理并发问题。生产环境至少需要对写入操作加锁或者使用带版本号的 CAS 机制。6.5 分支创建数量过多导致内存膨胀问题现象常见原因解决思路项目运行一段时间后内存占用率高每次操作都创建分支未及时丢弃增加分支生命周期管理验证完成后自动丢弃不再使用的分支数据分支并不是越多越好。建议分支命名带业务前缀并设置过期时间过期后自动清理。7. 最佳实践与工程建议7.1 分支命名规范分支命名需要能快速定位业务来源建议格式{bizType}-{scene}-{version}例如config-gray-v2 game-save-10023 tenant-10086-feature命名清晰排查问题时能少花很多时间。7.2 明确分支生命周期每个分支都应该有明确的创建目的和销毁时机。建议在架构设计阶段约定短生命周期分支用于 AB 实验、灰度验证验证完成后立即合并并丢弃长生命周期分支用于长期运行的租户配置需要定期备份临时调试分支使用后立即删除避免产生大量垃圾分支。7.3 权限与安全边界数据分支涉及主数据修改时必须遵守最小权限原则。不是所有应用都有权限创建 main 分支的子分支也不是所有应用都能执行 merge 操作。建议为 MemoryStore 增加权限判断参数例如public void merge(String source, String target, String operator) { checkPermission(operator, merge); // 执行合并逻辑 }生产环境的合并操作应当输出操作日志包括操作人、分支名、涉及 key 列表和时间戳。7.4 操作日志与审计记忆系统的调试难点在于数据来源不清晰。建议每次 put、remove、merge 都记录一条审计日志operatoradmin, actionput, branchfeature-v2, keyconfig.timeout, value2000, time2025-01-01 00:00:00没有日志的数据分支系统在出问题时几乎无法定位原因。7.5 注意序列化陷阱分支数据保存到文件或数据库时需要注意空值和 null 的区分TOMBSTONE 的序列化标识对象图的循环引用问题版本升级后字段变更的兼容性。如果对象图比较复杂建议优先使用 JSON 格式并为每个节点添加 type 字段。7.6 分集 1 之后的扩展方向本文实现的是单机内存版数据分支。后续分集可以继续扩展分支数据持久化到本地文件基于时间戳的冲突检测分支间的差异对比多级分支的合并策略从单机版演进到分布式版本使用分布式锁保证合并原子性与 Spring Boot 集成做成可注入的记忆系统组件。建议你按照这个顺序逐步迭代每完成一个版本就写一次测试不要一次性重构出“大而全”的系统。8. 总结这一期分集 1 主要完成了三件事第一梳理了 oGMemory 记忆系统的基本边界明确它与传统缓存的差异。第二讲清楚了数据分支的核心模型包括主分支、子分支、读写隔离、删除标记和合并操作。第三用完整 Java 代码实现了一个可运行的内存版数据分支系统并进行了验证。数据分支本身不是一个复杂的算法但它解决的问题非常实际。多版本配置、灰度发布、游戏存档、租户隔离这些常见业务场景都离不开分支思维。如果你刚开始接触这类系统建议先手动敲一遍这段代码然后试着给它添加一个“分支对比”功能或者增加一个“基于时间戳的冲突检测”。动手之后你会对对象图记忆系统有更深的理解。下一期可以继续讨论数据分支的持久化和冲突解决这部分一旦展开难度会明显上一个台阶。先把这个基础版本跑起来后续内容更好消化。