涉及多数据源的双写问题

在协作空间的场景中,当用户使用 Excel 协作文档的编辑功能,会涉及到一个并发控制的问题。目前,我在设计上采用乐观锁,即使用 CAS + 版本号(Version)来规避这个读写冲突的问题。但是其中,还有很多问题值得讨论。

Excel 文件存在哪里?如何管理?

它是一个半结构化的数据,其文件本身存储在 MinIO 管理的对象桶中,元数据(数据项 Id,映射的 StorageKey, 文件 MD5, 对应的逻辑存储路径,版本号等等)存储在关系型数据库 MySQL 中。我们的程序可以通过维护这张元数据表来实现对文件的 CRUD 管理。

如何实现对一个 Excel 文件的在线编辑?

先来讲一下一个操作行为是如何发生并且被最终落实到数据库的存储的。

  1. 前端:用户操作基于 x-sheet 的依赖包进行修改,对于一个单元格的修改,记录为一个 JS 数据条目结构,达到预设定条件后,向后端对应路由发起请求,以 JSON 的数据格式传递该数据。
  2. Java 程序:经过滤器后接收拦截请求,在 Controller 层,Spring 框架将 JSON 数据格式转换为 Java 可管理的对象( HiExcelSaveReqVO)。该数据中定义了操作的文件 Id,对应的单元格行和列以定位,以及修改后的字符串内容 Content(其实结合 Excel 的特性,还可以考虑这个数据类型的定义),以及可能的样式变动。
  3. Java 程序:
    • Service 层执行业务逻辑。这涉及到两个核心的操作,从 MinIO 中存取数据,对 MySQL 的文件源数据表进行维护。
    • 先不谈论具体的先后关系(放在下一个小标题详细讨论),在实现上,程序根据 Id 从 MinIO 中拉取对应的文件,使用 Apache POI 依赖库,将该文件解析后的结果保留在内存中进行维护;由于我们已经获取了包含修改内容的 reqVO 对象,因此只需要调用 POI 库的对应方法,对对应位置的单元格进行调整即可(注意数组索引和 Excel 索引的问题)。最后将该结果返回至 MinIO 中。对源文件的操作以外,我们还需要维护 MySQL 中的元数据表,也就是说,根据存取结果及时进行更新。如果存取失败,还要有合适的异常处理,避免僵尸文件的堆积。

两个系统如何协同?

这个主要问题是因为,MinIO 是没有事务支持的。我们没有办法在保证两个系统的写入必定成功、回滚必定成功。

原本,考虑了很多问题:

  • 考虑到 MinIO 自带的临时文件概念,先写入临时文件,再写数据库元数据表,再将临时文件转为实际存储的内容。因为我们要考虑多种情况:
    • 如果先写 MinIO,如果数据库元数据表没能正常更新,那么 MinIO 就成了孤儿文件了;
    • 如果先写数据库,再写 MinIO,涉及到并发问题。如果写完之后,多线程并发。线程 B 的先传输完,而线程 A 因为某些传输问题,在之后才传输完毕。最终显示的是 B 的版本。这涉及到一个当前业务场景下,顺序写入是否是必需,以及某些覆盖是否可容忍的问题。
  • 这又引申出了一个问题,如果每次对内存中的该文件的任何操作都会发起读写,那么会造成大量的不必要的 IO 开销。怎么解决这个问题呢?

这些如果由我们从头组织代码,会涉及到非常多复杂的概念。也因此,引入了消息队列。它以简单清晰、职责分明的概念区分了操作的对象、行为、以及行为策略。

数据关联到的快照设计和版本号设计

在我们的文件管理模型中,涉及到两个版本控制概念,快照和版本。

快照,是目前 MinIO 中实际存储的文件内容。而日志,则记录了当前区间内,用户对该文件进行的操作,用版本号进行维护和控制。

热日志、冷日志

如前面的设计。

当用户的一条操作传输至后端时,我们的 POI 操作的解析对象修改实现了这条数据操作。好的,那么如何广播给别的用户,知道确实有这么一个操作发生了呢?我们当然可以使用 WebSocket。如何广播呢?传输什么讯息,让人们知道是发生了什么样的操作?不能是内存中的全量对象吧,否则会有很多冗余的开销。那如果不是全量对象,而是单条操作,那么对于我们的服务器侧,怎么才能顺利地观测到传输是否成功、是否失败、发生了什么呢?我们不能只靠协议本身,而必须要在业务层设计机制,以保障确认逻辑。

如果我们不记录存储这条数据项操作,如何控制这个传输的过程?该条数据项应该存储在哪里?放在 MySQL 中是一个很自然的想法,但是如果是这样,会不会发生性能瓶颈呢?

因此自然地引入,热数据(Redis)和冷数据(MySQL)的设计。


碎碎念:哇哦,日志!你的名字是——事件溯源(Event Sourcing)!

存储状态变化的过程,而不是状态本身。

这让我很惊讶,因为其实对于最初的我来讲,日志是一个很抽象的概念,它没有文件本身那么直观,也因此我一开始没有想到这个设计,只是考虑到说之后整个项目完善之后再看看日志设计,做一些基础的留存。

我虽然知道数据库设计很多依赖于日志,但是并没有非常重视它。

但在后续的不断演进和设计中,我发现其对于计算机来讲似乎并不是这样。

我们设计合理自洽的数据项,让其包含操作的状态机,那么这个数据项以及其计算结果对于计算机来讲就是非常直观可理解的。也就是说,对于一个确定了的操作序列,计算到末尾的时候其状态就是确定了的,而不是像我抽象地理解的那样,觉得日志就是不太可靠的。

日志不是“不可靠的中间产物”,而是“最可靠的第一性原理”

附录

一个状态机可以表示为五元组:

M=(S,s0,Σ,δ,F)M = (S, s₀, Σ, δ, F)

其中:

  • SS:所有可能状态的集合(在本场景中,是 Excel 文件所有可能内容的集合)
  • s0Ss₀ ∈ S:初始状态(空文件或模板文件)
  • ΣΣ:输入字母表(所有可能的用户操作,如 (,,),,,...{改(行,列,值), 插入行, 删除列, …}
  • δ:S×ΣSδ : S × Σ → S:状态转移函数(给定当前状态和一个操作,得到新状态)
  • FSF ⊆ S:终止状态集合(可选,本场景中可忽略)

对于一组确定顺序的操作序列 σ1,σ2,...,σnσ₁, σ₂, …, σₙ ,状态转移可以写为函数的复合:

sn=δ(...δ(δ(s0,σ1),σ2)...,σn)sₙ = δ( … δ(δ(s₀, σ₁), σ₂) … , σₙ)

记  fσ(s)=δ(s,σ)f_{σ}(s) = δ(s, σ) 则:

sn=fσnfσn1...fσ1(s0)sₙ = f_{σₙ} ∘ f_{σ_{n-1}} ∘ … ∘ f_{σ₁}(s₀)

关键性质:只要 δδ 是确定性的(deterministic),那么对于给定的 s0s₀ 和操作序列,snsₙ 是唯一确定的。

快照 sns_n 只是操作序列的一个缓存结果。只要日志(操作序列)不丢失,就可以从 s0s₀ 开始重放,确定性地重建任意时刻的状态。

确定性条件

σΣ,qQ:δ(q,σ) 有且仅有一个结果\forall \sigma \in \Sigma, \forall q \in Q: \delta(q, \sigma) \text{ 有且仅有一个结果}

当满足上述条件时,给定初始状态 q₀ 和操作序列 σ₁, σ₂, …, σₙ,最终状态 q数学上唯一确定


对应到本项目:

数学概念本项目实现
初始状态 s0s₀MinIO 中存储的初始快照
操作 σσ前端记录的每一条编辑操作
转移函数 δApache POI 对内存中 Excel 对象的修改逻辑
操作序列 σ1...σnσ₁…σₙ存储在 Redis(热)和 MySQL(冷)中的日志
最终状态 snsₙMinIO 中存储的快照

为什么日志比快照更可靠?

快照 sᵢ 只是函数复合过程中的一个中间输出值;日志 σ₁, …, σᵢ 记录了完整的计算过程

一旦日志确定,任意时刻的状态都可以被精确重现:

i[1,n],qi=(fσi...fσ1)(q0)\forall i \in [1, n], q_i = (f_{\sigma_i} \circ … \circ f_{\sigma_1})(q_0)

这就是为什么:对于计算机而言,一个定义良好的操作序列,比它计算出的结果更可靠。文件可能被写坏,但日志可以忠实地重放。

看这样一个系统,第一反应不是问“它存了什么”,而是问“它记录了什么样的操作序列”。


参考资料:确定性有限状态机(DFSM / DFA)的形式化定义,参见 Hopcroft & Ullman 《自动机理论、语言与计算导论》。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇