2.3. 复制和冲突模型

让我们以以下示例来说明复制和冲突处理。

  • 爱丽丝有一个包含鲍勃名片的文档;

  • 她在台式机和笔记本电脑之间同步它;

  • 在台式机上,她更新了鲍勃的电子邮件地址;在没有再次同步的情况下,她在笔记本电脑上更新了鲍勃的手机号码;

  • 然后她再次将两者相互复制。

因此,在台式机上,该文档包含鲍勃的新电子邮件地址和他的旧手机号码,而在笔记本电脑上,它包含他的旧电子邮件地址和他的新手机号码。

问题是,这些冲突的更新文档会发生什么?

2.3.1. CouchDB 复制

CouchDB 在数据库中使用 JSON 文档。数据库的复制通过 HTTP 进行,可以是“拉取”或“推送”,但它是单向的。因此,执行完全同步的最简单方法是执行“推送”然后执行“拉取”(反之亦然)。

因此,爱丽丝创建了 v1 并同步它。她在一边更新到 v2a,在另一边更新到 v2b,然后复制。会发生什么?

答案很简单:两个版本都存在于两边!

  DESKTOP                          LAPTOP
+---------+
| /db/bob |                                     INITIAL
|   v1    |                                     CREATION
+---------+

+---------+                      +---------+
| /db/bob |  ----------------->  | /db/bob |     PUSH
|   v1    |                      |   v1    |
+---------+                      +---------+

+---------+                      +---------+  INDEPENDENT
| /db/bob |                      | /db/bob |     LOCAL
|   v2a   |                      |   v2b   |     EDITS
+---------+                      +---------+

+---------+                      +---------+
| /db/bob |  ----------------->  | /db/bob |     PUSH
|   v2a   |                      |   v2a   |
+---------+                      |   v2b   |
                                 +---------+

+---------+                      +---------+
| /db/bob |  <-----------------  | /db/bob |     PULL
|   v2a   |                      |   v2a   |
|   v2b   |                      |   v2b   |
+---------+                      +---------+

毕竟,这不是文件系统,因此没有限制说只有一个文档可以存在于 /db/bob 的名称下。这些只是相同名称下的“冲突”修订版。

由于更改始终被复制,因此数据是安全的。两台机器都拥有两个文档的相同副本,因此任何一方硬盘驱动器发生故障都不会丢失任何更改。

需要注意的另一件事是,对等体不需要配置或跟踪。您可以定期复制到对等体,也可以执行一次性、临时推送或拉取。复制完成后,不会保留任何记录来记录任何特定文档或修订版来自哪个对等体。

因此,现在的问题是:当您尝试读取 /db/bob 时会发生什么?默认情况下,CouchDB 会使用确定性算法选择一个任意的修订版作为“获胜者”,以便在所有对等体上做出相同的选择。视图也是如此:确定性选择的获胜者是唯一馈送到映射函数的修订版。

假设获胜者是 v2a。在台式机上,如果爱丽丝读取文档,她将看到 v2a,这正是她在那里保存的内容。但在笔记本电脑上,复制后,她也只会看到 v2a。看起来她在那里做的更改似乎丢失了 - 但当然没有,它们只是作为冲突的修订版隐藏起来。但最终她需要将这些更改合并到鲍勃的名片中,否则它们将实际上丢失。

任何明智的名片应用程序至少都必须将冲突的版本呈现给爱丽丝,并允许她创建一个包含所有版本信息的版本。理想情况下,它会自动合并更新。

2.3.2. 冲突避免

在单个节点上工作时,CouchDB 会通过返回 409 冲突 错误来避免创建冲突的修订版。这是因为,当您 PUT 文档的新版本时,您必须给出先前版本的 _rev。如果该 _rev 已经被取代,则更新将被拒绝,并返回 409 冲突 响应。

因此,假设同一个节点上的两个用户正在获取鲍勃的名片,并同时更新它,然后写回它

USER1    ----------->  GET /db/bob
         <-----------  {"_rev":"1-aaa", ...}

USER2    ----------->  GET /db/bob
         <-----------  {"_rev":"1-aaa", ...}

USER1    ----------->  PUT /db/bob?rev=1-aaa
         <-----------  {"_rev":"2-bbb", ...}

USER2    ----------->  PUT /db/bob?rev=1-aaa
         <-----------  409 Conflict  (not saved)

用户 2 的更改被拒绝,因此应用程序需要再次获取 /db/bob,然后执行以下操作:

  1. 应用与先前修订版相同的更改,并提交新的 PUT

  2. 重新显示文档,以便用户必须再次编辑它

  3. 只需用之前保存的文档覆盖它(不建议这样做,因为用户 1 的更改将被静默丢失)

因此,在这种模式下工作时,您的应用程序仍然必须能够处理这些冲突并具有合适的重试策略,但这些冲突永远不会进入数据库本身。

2.3.3. 修订树

当您在 CouchDB 中更新文档时,它会保留先前修订版的列表。在引入冲突更新的情况下,此历史记录会分支成一棵树,其中该文档的当前冲突修订版构成这棵树的顶端(叶节点)

  ,--> r2a
r1 --> r2b
  `--> r2c

然后每个分支都可以扩展其历史记录 - 例如,如果您读取修订版 r2b,然后使用 ?rev=r2b 执行 PUT,那么您将在该特定分支上创建一个新的修订版。

  ,--> r2a -> r3a -> r4a
r1 --> r2b -> r3b
  `--> r2c -> r3c

这里,(r4a, r3b, r3c) 是冲突修订版的集合。解决冲突的方法是删除其他分支上的叶节点。因此,当您将 (r4a+r3b+r3c) 合并到单个合并文档中时,您将替换 r4a 并删除 r3b 和 r3c。

  ,--> r2a -> r3a -> r4a -> r5a
r1 --> r2b -> r3b -> (r4b deleted)
  `--> r2c -> r3c -> (r4c deleted)

请注意,r4b 和 r4c 仍然作为历史记录树中的叶节点存在,但作为已删除的文档。您可以检索它们,但它们将被标记为 "_deleted":true

当您压缩数据库时,所有非叶文档的主体都会被丢弃。但是,为了将来在遇到数据库的任何旧副本时进行冲突解决,会保留历史 _revs 列表。存在“修订版修剪”以防止其变得任意大。

2.3.4. 处理冲突文档

基本的 GET /{db}/{docid} 操作不会显示有关冲突的任何信息。您只会看到确定性选择的获胜者,并且不会收到任何指示来表明是否存在其他冲突的修订版。

{
    "_id":"test",
    "_rev":"2-b91bb807b4685080c6a651115ff558f5",
    "hello":"bar"
}

如果您执行 GET /db/test?conflicts=true,并且文档处于冲突状态,那么您将获得获胜者以及一个 _conflicts 成员,其中包含其他冲突修订版(s) 的 revs 数组。然后,您可以使用后续的 GET /db/test?rev=xxxx 操作分别获取它们。

{
    "_id":"test",
    "_rev":"2-b91bb807b4685080c6a651115ff558f5",
    "hello":"bar",
    "_conflicts":[
        "2-65db2a11b5172bf928e3bcf59f728970",
        "2-5bc3c6319edf62d4c624277fdd0ae191"
    ]
}

如果您执行 GET /db/test?open_revs=all,那么您将获得修订树的所有叶节点。这将为您提供所有当前冲突,但也会为您提供已删除的叶节点(即冲突历史记录中已解决的部分)。您可以通过过滤掉 "_deleted":true 的文档来删除它们。

[
    {"ok":{"_id":"test","_rev":"2-5bc3c6319edf62d4c624277fdd0ae191","hello":"foo"}},
    {"ok":{"_id":"test","_rev":"2-65db2a11b5172bf928e3bcf59f728970","hello":"baz"}},
    {"ok":{"_id":"test","_rev":"2-b91bb807b4685080c6a651115ff558f5","hello":"bar"}}
]

"ok" 标签是 open_revs 的一个产物,它还允许您将显式修订版列为 JSON 数组,例如 open_revs=[rev1,rev2,rev3]。在这种形式下,可以请求现在丢失的修订版,因为数据库已被压缩。

注意

open_revs=all 返回的修订版顺序与确定性“获胜”算法无关。在上面的示例中,获胜修订版是 2-b91b… 并且恰好最后返回,但在其他情况下,它可能在不同的位置返回。

检索到所有冲突的修订版后,您的应用程序可以选择将它们全部显示给用户。或者,它可以尝试合并它们,写回合并的版本,并删除冲突的版本 - 也就是说,永久解决冲突。

如上所述,您需要更新一个修订版并显式删除所有冲突的修订版。这可以通过对 _bulk_docs 执行单个 POST 来完成,在您要删除的那些修订版上设置 "_deleted":true

2.3.5. 多文档 API

2.3.5.1. 使用 Mango 查找冲突文档

版本 2.2.0 中的新功能。

CouchDB 的 Mango 系统 允许轻松查询具有冲突的文档,并返回每个文档的完整主体。

以下是如何使用它来查找数据库中的所有冲突

$ curl -X POST http://127.0.0.1/dbname/_find \
    -d '{"selector": {"_conflicts": { "$exists": true}}, "conflicts": true}' \
    -Hcontent-type:application/json
{"docs": [
{"_id":"doc","_rev":"1-3975759ccff3842adf690a5c10caee42","a":2,"_conflicts":["1-23202479633c2b380f79507a776743d5"]}
],
"bookmark": "g1AAAABheJzLYWBgYMpgSmHgKy5JLCrJTq2MT8lPzkzJBYozA1kgKQ6YVA5QkBFMgKSVDHWNjI0MjEzMLc2MjZONkowtDNLMLU0NzBPNzc3MTYxTTLOysgCY2ReV"}

如果需要,可以使用 bookmark 值浏览其他页面结果。默认情况下,Mango 每个请求只返回 25 个结果。

如果您预期经常运行此查询,请务必创建 Mango 辅助索引以加快查询速度。

$ curl -X POST http://127.0.0.1/dbname/_index \
    -d '{"index":{"fields": ["_conflicts"]}}' \
    -Hcontent-type:application/json

当然,可以选择器来增强以根据文档中的其他键过滤文档。请确保将这些键也添加到辅助索引中,否则将触发完整数据库扫描。

2.3.5.2. 使用 _all_docs 索引查找冲突文档

您可以使用视图上的 include_docs=true 同时获取多个文档。但是,conflicts=true 请求将被忽略;值的“doc”部分永远不会包含 _conflicts 成员。因此,您需要执行另一个查询来确定每个文档是否处于冲突状态。

$ curl 'http://127.0.0.1:5984/conflict_test/_all_docs?include_docs=true&conflicts=true'
{
    "total_rows":1,
    "offset":0,
    "rows":[
        {
            "id":"test",
            "key":"test",
            "value":{"rev":"2-b91bb807b4685080c6a651115ff558f5"},
            "doc":{
                "_id":"test",
                "_rev":"2-b91bb807b4685080c6a651115ff558f5",
                "hello":"bar"
            }
        }
    ]
}
$ curl 'http://127.0.0.1:5984/conflict_test/test?conflicts=true'
{
    "_id":"test",
    "_rev":"2-b91bb807b4685080c6a651115ff558f5",
    "hello":"bar",
    "_conflicts":[
        "2-65db2a11b5172bf928e3bcf59f728970",
        "2-5bc3c6319edf62d4c624277fdd0ae191"
    ]
}

2.3.6. 视图映射函数

视图只获取文档的获胜修订版。但是,如果存在任何冲突的修订版,它们也会获得 _conflicts 成员。这意味着您可以编写一个视图,其工作专门用于定位具有冲突的文档。这是一个实现此目的的简单映射函数

function(doc) {
    if (doc._conflicts) {
        emit(null, [doc._rev].concat(doc._conflicts));
    }
}

这将给出以下输出

{
    "total_rows":1,
    "offset":0,
    "rows":[
        {
            "id":"test",
            "key":null,
            "value":[
                "2-b91bb807b4685080c6a651115ff558f5",
                "2-65db2a11b5172bf928e3bcf59f728970",
                "2-5bc3c6319edf62d4c624277fdd0ae191"
            ]
        }
    ]
}

如果您这样做,您可以拥有一个单独的“扫描”进程,该进程定期扫描您的数据库,查找具有冲突的文档,获取冲突的修订版,并解决它们。

虽然这使主应用程序保持简单,但这种方法的问题在于,在引入冲突和解决冲突之间会有一段时间窗口。从用户的角度来看,这可能看起来像是他们刚刚成功保存的文档可能会突然丢失他们的更改,然后在一段时间后恢复。这可能是可以接受的,也可能不是。

此外,很容易忘记启动扫描器,或者没有正确实现它,这将引入奇怪的行为,这些行为将难以追踪。

CouchDB 的“获胜”修订版算法可能意味着信息会从视图中消失,直到冲突得到解决。再次考虑 Bob 的名片;假设 Alice 有一个发出手机号码的视图,以便她的电话应用程序可以根据来电显示显示来电者的姓名。如果存在带有 Bob 的旧手机号码和新手机号码的冲突文档,并且它们恰好被解决为 Bob 的旧号码,那么该视图将无法识别他的新号码。在这种特定情况下,应用程序可能更愿意将来自两个冲突文档的信息放入视图中,但这目前是不可能的。

建议的算法用于获取具有冲突解决的文档

  1. 通过 GET docid?conflicts=true 请求获取文档

  2. 对于 _conflicts 数组中的每个成员,调用 GET docid?rev=xxx。如果在此阶段出现任何错误,请从步骤 1 重新开始。(可能存在竞争,其中其他人已经解决了此冲突并删除了该修订版)

  3. 执行特定于应用程序的合并

  4. 使用对第一个修订版的更新和对其他修订版的删除写入 _bulk_docs

这可以在每次读取时完成(在这种情况下,您可以用执行上述操作的库调用替换应用程序中的所有 GET 调用),也可以作为扫描器代码的一部分完成。

以下是用 Ruby 使用低级 RestClient 的示例

require 'rubygems'
require 'rest_client'
require 'json'
DB="http://127.0.0.1:5984/conflict_test"

# Write multiple documents
def writem(docs)
    JSON.parse(RestClient.post("#{DB}/_bulk_docs", {
        "docs" => docs,
    }.to_json))
end

# Write one document, return the rev
def write1(doc, id=nil, rev=nil)
    doc['_id'] = id if id
    doc['_rev'] = rev if rev
    writem([doc]).first['rev']
end

# Read a document, return *all* revs
def read1(id)
    retries = 0
    loop do
        # FIXME: escape id
        res = [JSON.parse(RestClient.get("#{DB}/#{id}?conflicts=true"))]
        if revs = res.first.delete('_conflicts')
            begin
                revs.each do |rev|
                    res << JSON.parse(RestClient.get("#{DB}/#{id}?rev=#{rev}"))
                end
            rescue
                retries += 1
                raise if retries >= 5
                next
            end
        end
        return res
    end
end

# Create DB
RestClient.delete DB rescue nil
RestClient.put DB, {}.to_json

# Write a document
rev1 = write1({"hello"=>"xxx"},"test")
p read1("test")

# Make three conflicting versions
write1({"hello"=>"foo"},"test",rev1)
write1({"hello"=>"bar"},"test",rev1)
write1({"hello"=>"baz"},"test",rev1)

res = read1("test")
p res

# Now let's replace these three with one
res.first['hello'] = "foo+bar+baz"
res.each_with_index do |r,i|
    unless i == 0
        r.replace({'_id'=>r['_id'], '_rev'=>r['_rev'], '_deleted'=>true})
    end
end
writem(res)

p read1("test")

以这种方式编写的应用程序永远不必处理 PUT 409,并且自动具有多主能力。

您可以看到,当您知道自己在做什么时,它非常简单。只是 CouchDB 目前没有提供方便的 HTTP API 用于“获取所有冲突的修订版”,也没有提供“PUT 以取代这些 N 个修订版”,因此您需要自己包装这些。在撰写本文时,没有已知的客户端库提供对此的支持。

2.3.7. 合并和修订版历史记录

实际执行合并是特定于应用程序的功能。它取决于数据的结构。有时这很容易:例如,如果文档包含一个列表,该列表只会被追加,那么您可以执行两个列表版本的并集。

一些合并策略会查看对对象所做的更改,与它之前的版本相比。这就是 Git 的合并函数的工作方式。

例如,要合并 Bob 的名片版本 v2a 和 v2b,您可以查看 v1 和 v2b 之间的差异,然后将这些更改也应用于 v2a。

使用 CouchDB,您有时可以获取文档的旧修订版。例如,如果您获取 /db/bob?rev=v2b&revs_info=true,您将获得一个包含最终导致修订版 v2b 的先前修订版 ID 的列表。对 v2a 执行相同的操作,您可以找到它们的共同祖先修订版。但是,如果数据库已被压缩,则该文档修订版的内容将丢失。 revs_info 仍然会显示 v1 是一个祖先,但会将其报告为“丢失”

BEFORE COMPACTION           AFTER COMPACTION

     ,-> v2a                     v2a
   v1
     `-> v2b                     v2b

因此,如果您想使用差异,建议的方法是在新修订版本身中存储这些差异。也就是说:当您用 v2a 替换 v1 时,在 v2a 中包含一个额外的字段或附件,说明从 v1 到 v2a 更改了哪些字段。不幸的是,这确实意味着您的应用程序需要额外的簿记工作。

2.3.8. 与其他复制数据存储的比较

其他复制系统也会出现相同的问题,因此查看这些系统并了解它们与 CouchDB 的比较可能会有启发。请随时添加其他示例。

2.3.8.1. Unison

Unison 是一种双向文件同步工具。在这种情况下,名片将是一个文件,例如 bob.vcf

当您运行 unison 时,更改会双向传播。如果一个文件在一侧更改了,但在另一侧没有更改,则新的文件将替换旧的文件。Unison 保持一个本地状态文件,以便它知道自上次成功复制以来文件是否已更改。

在我们的示例中,它在两侧都发生了变化。文件系统中只能存在一个名为 bob.vcf 的文件。Unison 通过简单地回避来解决这个问题:用户可以选择用本地版本替换远程版本,反之亦然(这两种方法都会丢失数据),但默认操作是保持两侧不变。

从 Alice 的角度来看,这至少是一个简单的解决方案。无论何时她在桌面上,她都会看到她上次在桌面上编辑的版本,无论何时她在笔记本电脑上,她都会看到她上次在笔记本电脑上编辑的版本。

但由于没有实际发生复制,因此数据没有得到保护。如果她的笔记本电脑硬盘驱动器损坏,她将丢失她在笔记本电脑上进行的所有更改;如果她的台式机硬盘驱动器损坏,也是如此。

她必须手动复制其中一个版本(在不同的文件名下),合并这两个版本,然后最终将合并后的版本推送到另一侧。

还要注意,原始文件(版本 v1)此时已丢失。因此,仅凭检查无法知道 v2a 或 v2b 哪个包含 Bob 最新的电子邮件地址,或者哪个版本包含最新的手机号码。Alice 必须记住她最后输入了哪个。

2.3.8.2. Git

Git 是一个众所周知的分布式源代码控制系统。与 Unison 一样,Git 处理文件。但是,Git 将一组文件的整个状态视为一个单一对象,即“树”。无论何时您保存更新,您都会创建一个“提交”,它指向更新的树和之前的提交,而之前的提交又指向之前的树。因此,您拥有所有文件状态的完整历史记录。此历史记录形成一个分支,并且一个指针被保留到分支的顶端,您可以从该指针向后跟踪到任何先前状态。“指针”是顶端提交的 SHA1 哈希值。

如果您正在与一个或多个对等方进行复制,则会为每个对等方创建一个单独的分支。例如,您可能拥有

main               -- my local branch
remotes/foo/main   -- branch on peer 'foo'
remotes/bar/main   -- branch on peer 'bar'

在常规工作流程中,复制是一个“拉取”,将更改从远程对等方导入本地存储库。“拉取”执行两件事:首先将对等方的状态“获取”到该对等方的远程跟踪分支中;然后尝试将这些更改“合并”到本地分支中。

现在让我们考虑名片。Alice 创建了一个包含 bob.vcf 的 Git 存储库,并将其克隆到另一台机器上。分支看起来像这样,其中 AAAAAAAA 是提交的 SHA1

---------- desktop ----------           ---------- laptop ----------
main: AAAAAAAA                        main: AAAAAAAA
remotes/laptop/main: AAAAAAAA         remotes/desktop/main: AAAAAAAA

现在她在桌面上进行更改,并将其提交到桌面存储库中;然后她在笔记本电脑上进行不同的更改,并将其提交到笔记本电脑存储库中

---------- desktop ----------           ---------- laptop ----------
main: BBBBBBBB                        main: CCCCCCCC
remotes/laptop/main: AAAAAAAA         remotes/desktop/main: AAAAAAAA

现在在桌面上,她执行 git pull laptop。首先,远程对象被复制到本地存储库中,远程跟踪分支被更新

---------- desktop ----------           ---------- laptop ----------
main: BBBBBBBB                        main: CCCCCCCC
remotes/laptop/main: CCCCCCCC         remotes/desktop/main: AAAAAAAA

注意

存储库仍然包含 AAAAAAAA,因为提交 BBBBBBBB 和 CCCCCCCC 指向它。

然后 Git 将尝试合并这些更改。知道 CCCCCCCC 的父提交是 AAAAAAAA,它获取 AAAAAAAACCCCCCCC 之间的差异,并尝试将其应用于 BBBBBBBB

如果成功,您将获得一个带有合并提交的新版本

---------- desktop ----------           ---------- laptop ----------
main: DDDDDDDD                        main: CCCCCCCC
remotes/laptop/main: CCCCCCCC         remotes/desktop/main: AAAAAAAA

然后 Alice 必须登录笔记本电脑并运行 git pull desktop。会发生类似的过程。远程跟踪分支被更新

---------- desktop ----------           ---------- laptop ----------
main: DDDDDDDD                        main: CCCCCCCC
remotes/laptop/main: CCCCCCCC         remotes/desktop/main: DDDDDDDD

然后进行合并。这是一个特殊情况:CCCCCCCCDDDDDDDD 的父提交之一,因此笔记本电脑可以快进CCCCCCCC 直接更新到 DDDDDDDD,而无需执行任何复杂的合并。这将最终状态保留为

---------- desktop ----------           ---------- laptop ----------
main: DDDDDDDD                        main: DDDDDDDD
remotes/laptop/main: CCCCCCCC         remotes/desktop/main: DDDDDDDD

现在这一切都很好,但您可能想知道这与考虑 CouchDB 有何关系。

首先,请注意合并算法失败时会发生什么。更改仍然从远程存储库传播到本地存储库,并且在远程跟踪分支中可用。因此,与 Unison 不同,您知道数据得到了保护。只是本地工作副本可能无法更新,或者可能与远程版本不同。您需要自己创建和提交组合版本,但您保证拥有执行此操作所需的所有历史记录。

请注意,虽然可以在 Git 中构建新的合并算法,但标准算法侧重于对源代码的基于行的更改。如果 XML 或 JSON 没有任何换行符,它们对 XML 或 JSON 的效果不佳。

另一个有趣的考虑因素是多个对等方。在这种情况下,您有多个远程跟踪分支,其中一些可能与您的本地分支匹配,一些可能落后于您,一些可能领先于您(即包含您尚未合并的更改)

main: AAAAAAAA
remotes/foo/main: BBBBBBBB
remotes/bar/main: CCCCCCCC
remotes/baz/main: AAAAAAAA

请注意,每个对等节点都明确跟踪,因此必须明确创建。如果对等节点变得陈旧或不再需要,您需要从配置中将其删除并删除远程跟踪分支。这与 CouchDB 不同,CouchDB 不会在数据库中保留任何对等节点状态。

CouchDB 和 Git 之间的另一个区别在于,Git 会维护从时间零开始的所有历史记录 - Git 压缩会保留所有这些版本之间的差异,以减小大小,但 CouchDB 会丢弃它们。如果您不断更新文档,Git 仓库的大小会无限增长。可以使用“历史记录重写”(需要一些努力)让 Git 忘记特定版本之前的提交。

2.3.8.2.1. 什么是 CouchDB 复制协议?它像 Git 吗?

作者:

Jason Smith

日期:

2011-01-29

来源:

StackOverflow

要点

如果您了解 Git,那么您就知道 Couch 复制的工作原理。复制与使用 Git 等分布式源代码管理工具进行推送或拉取非常相似。

CouchDB 复制没有自己的协议。复制器只需作为客户端连接到两个数据库,然后从一个数据库读取数据并写入另一个数据库。推送复制是读取本地数据并更新远程数据库;拉取复制则相反。

  • 有趣的事实 1:复制器实际上是一个独立的 Erlang 应用程序,在自己的进程中运行。它连接到两个 Couch 数据库,然后从一个数据库读取记录并将其写入另一个数据库。

  • 有趣的事实 2:CouchDB 无法识别谁是普通客户端,谁是复制器(更不用说复制是推送还是拉取)。它们看起来都像是客户端连接。其中一些读取记录。其中一些写入记录。

一切从数据模型开始

复制算法很简单,没有意思。一只训练有素的猴子也能设计出来。它之所以简单,是因为它的巧妙之处在于数据模型,数据模型具有以下有用的特性

  1. CouchDB 中的每条记录都与其他所有记录完全独立。如果您想执行 JOIN 或事务,这很糟糕,但如果您想编写复制器,这很棒。只需弄清楚如何复制一条记录,然后对每条记录重复此操作。

  2. 与 Git 一样,记录具有链接列表修订历史记录。记录的修订 ID 是其自身数据的校验和。后续修订 ID 是以下内容的校验和:新数据加上先前修订 ID。

  3. 除了应用程序数据({"name": "Jason", "awesome": true})之外,每条记录还存储所有先前修订 ID 的演化时间线,这些时间线一直到自身。

    • 练习:花点时间静静地思考。考虑任何两个不同的记录 A 和 B。如果 A 的修订 ID 出现在 B 的时间线中,那么 B 一定是从 A 演化而来的。现在考虑 Git 的快进合并。你听到那个声音了吗?那是你的思想被震撼的声音。

  4. Git 并不是真正的线性列表。它有分支,当一个父级有多个子级时。CouchDB 也有。

    • 练习:比较两个不同的记录 A 和 B。A 的修订 ID 没有出现在 B 的时间线中;但是,一个修订 ID C 出现在 A 和 B 的时间线中。因此,A 没有从 B 演化而来。B 没有从 A 演化而来。而是 A 和 B 有一个共同的祖先 C。在 Git 中,这是一个“分支”。在 CouchDB 中,这是一个“冲突”。

    • 在 Git 中,如果两个子级继续独立发展它们的时间线,那很好。分支完全支持这一点。

    • 在 CouchDB 中,如果两个子级继续独立发展它们的时间线,那也很好。冲突完全支持这一点。

    • 有趣的事实 3:CouchDB 的“冲突”与 Git 的“冲突”不对应。Couch 冲突是不同的修订历史记录,Git 称之为“分支”。因此,CouchDB 社区将“冲突”发音为“co-flicked”,其中“n”不发音。

  5. Git 也有合并,当一个子级有多个父级时。CouchDB 也有点像这样。

    • 在数据模型中,没有合并。客户端只需将一条时间线标记为已删除,并继续使用唯一现存的时间线。

    • 在应用程序中,感觉就像合并。通常,客户端会以特定于应用程序的方式合并每条时间线中的数据。然后,它将新数据写入时间线。在 Git 中,这就像将分支 A 中的更改复制粘贴到分支 B 中,然后提交到分支 B 并删除分支 A。数据已合并,但没有进行git merge

    • 这些行为之所以不同,是因为在 Git 中,时间线本身很重要;但在 CouchDB 中,数据很重要,时间线是附带的——它只是为了支持复制而存在。这就是 CouchDB 的内置修订功能不适合存储像维基页面这样的修订数据的原因之一。

最后说明

本文中至少有一句话(可能是这句话)是完全胡说八道。