4.1. 文档设计注意事项

在设计您的数据库和文档结构时,需要考虑许多最佳实践。对于习惯使用关系型数据库的人来说,其中一些技术可能并不明显。

4.1.1. 不要依赖 CouchDB 的自动 UUID 生成

虽然 CouchDB 会为您创建的任何文档的 _id 字段生成一个唯一的标识符,但在大多数情况下,出于以下几个原因,您最好自己生成它们。

  • 如果由于任何原因您错过了 CouchDB 的 200 OK 回复,并且尝试再次存储文档,您最终将在多个 _id 下存储相同的文档内容。这很容易发生在中间代理和缓存系统中,这些系统可能不会通知开发人员失败的事务正在重试。

  • _id 是 CouchDB 中唯一强制执行的值,因此您不妨利用它。CouchDB 将其文档存储在 B+ 树中。每个添加或更新的文档都存储为叶节点,并且可能需要重写中间节点和父节点。如果您能够自己安排它们按顺序排列,那么您可能能够比自动生成的 ID 更有效地利用自己排序的 ID。

4.1.2. 自动递增序列的替代方案

由于复制以及 CouchDB 的分布式性质,在 CouchDB 中使用自动递增序列是不切实际的。这些通常用于确保数据库表中每一行都有唯一的标识符。CouchDB 自己生成唯一的 ID,您也可以指定自己的 ID,因此您实际上不需要序列。如果您将序列用于其他用途,那么最好找到另一种方法在 CouchDB 中以其他方式表达它。

4.1.3. 预先聚合您的数据

如果您的 CouchDB 用途是收集和报告模型,而不是实时视图,那么您可能不需要为记录的每个事件存储一个单独的文档。在这种情况下,预先聚合您的数据可能是一个好主意。如果您只是试图跟踪这些文档的汇总统计信息,那么您可能不需要每秒 1000 个文档。这减少了 CouchDB 的 MapReduce 引擎的计算压力,也减少了其存储需求。

在这种情况下,使用内存存储来汇总您的统计信息,然后每 10 秒 / 1 分钟 / 任何您需要的粒度写入 CouchDB,将大大减少您放入数据库中的文档数量。

之后,您可以通过遍历整个数据库并生成要存储在具有较低粒度的新数据库中的文档来进一步 减少 您的数据(例如,每天 1 个文档)。完成之后,您可以删除较旧、粒度更细的数据库。

4.1.4. 设计应用程序以配合复制

虽然 CouchDB 包括复制和冲突标记机制,但这并不是构建以用户期望的方式复制的应用程序的全部内容。

这里我们考虑一个简单的书签应用程序示例。这个想法是用户可以复制自己的书签,在另一台机器上使用它们,然后稍后同步他们的更改。

让我们从一个非常简单的书签定义开始:一个有序的、可嵌套的名称到 URL 的映射。在内部,应用程序可能像这样表示它

[
  {"name":"Weather", "url":"http://www.bbc.co.uk/weather"},
  {"name":"News", "url":"http://news.bbc.co.uk/"},
  {"name":"Tech", "bookmarks": [
    {"name":"Register", "url":"http://www.theregister.co.uk/"},
    {"name":"CouchDB", "url":"https://couchdb.cn/"}
  ]}
]

然后它可以通过遍历此结构来呈现书签菜单和子菜单。

现在考虑以下场景:用户在她的 PC 上有一组书签,然后将其复制到她的笔记本电脑上。在笔记本电脑上,她将新闻链接更改为指向 CNN,将“注册”重命名为“The Register”,并在其后添加一个指向 slashdot 的新链接。在台式机上,她的丈夫删除了天气链接,并在科技文件夹中添加了一个指向 CNET 的新链接。

因此,在这些更改之后,笔记本电脑有

[
  {"name":"Weather", "url":"http://www.bbc.co.uk/weather"},
  {"name":"News", "url":"http://www.cnn.com/"},
  {"name":"Tech", "bookmarks": [
    {"name":"The Register", "url":"http://www.theregister.co.uk/"},
    {"name":"Slashdot", "url":"http://www.slashdot.new/"},
    {"name":"CouchDB", "url":"https://couchdb.cn/"}
  ]}
]

而 PC 有

[
  {"name":"News", "url":"http://www.cnn.com/"},
  {"name":"Tech", "bookmarks": [
    {"name":"Register", "url":"http://www.theregister.co.uk/"},
    {"name":"CouchDB", "url":"https://couchdb.cn/"},
    {"name":"CNET", "url":"http://news.cnet.com/"}
  ]}
]

在下次同步时,我们希望进行预期的合并。也就是说:在一侧更改、添加或删除的链接也在另一侧更改、添加或删除 - 无需人工干预,除非绝对必要。

我们还将假设双方定期执行 CouchDB 的“压缩”操作,并且在重新同步之前断开连接的时间超过此时间。

所有允许自动合并更改的方法都依赖于拥有某种历史记录,追溯到副本分歧的点。

CouchDB 本身没有提供这种机制。它为一个文档存储任意数量的旧 _id(trunk 现在有一个修剪 _id 历史记录的机制),用于复制目的。但是,除了存在文档的冲突版本之外,它不会在压缩周期中保留文档本身。

不要依赖 CouchDB 修订历史记录机制来帮助您构建应用程序级版本历史记录。它的唯一目的是确保数据库之间最终一致的复制。您需要以对您的应用程序有意义的任何形式显式维护历史记录,并将其修剪以避免过度使用存储,同时不要修剪到活副本最后分歧的点之前。

4.1.4.1. 方法 1:单个 JSON 文档

上面的结构已经是有效的 JSON,因此可以通过将其包装在一个对象中并存储为单个文档来在 CouchDB 中表示

{
  "bookmarks":
  // ... same as above
}

这使得应用程序的生活变得非常容易,因为所有排序和嵌套都已处理。这里的问题是,在复制时,只有两组书签可见:示例 B 和示例 C。其中一组将被选为主要修订版,另一组将被存储为冲突修订版。

此时,语义从用户的角度来看非常不令人满意。最好的方法是提供一个选择,说“您希望保留这两组书签中的哪一组:B 或 C?”但是,两者都不代表期望的结果。由于丢失了基本修订版 A,因此没有足够的数据能够正确地合并它们。

这对用户来说将非常不令人满意,用户将不得不手动再次应用一组更改。

4.1.4.2. 方法 2:每个书签一个单独的文档

另一种解决方案是将每个字段(书签)作为独立的文档。添加或删除书签只是添加或删除文档,这永远不会冲突(尽管如果在两侧都添加了相同的书签,那么您最终将获得两个副本)。更改书签只会发生冲突,如果两侧都对同一个书签进行了更改,然后才合理地要求用户在它们之间进行选择。

由于现在将有很多小文档,您可能希望为书签保留一个完全独立的数据库,或者在数据库中添加一个属性来区分书签和其他类型的文档。在后一种情况下,可以创建一个视图来仅返回书签文档。

虽然复制现在已修复,但需要注意书签的“有序”和“可嵌套”属性。

对于排序,一个建议是为每个项目提供一个浮点索引,然后在 A 和 B 之间插入一个对象时,为其提供一个索引,该索引是 A 和 B 索引的平均值。不幸的是,当您用完精度后,这将失败一段时间,用户会感到困惑,因为他们最近的书签不再记得他们放置的准确位置。

更好的方法是保留索引的字符串表示,随着树的细分,该字符串可以增长。这不会遇到上述问题,但随着时间的推移,这可能会导致该字符串变得任意长。它们可以重新编号,但重新编号操作可能会引入很多冲突,尤其是在双方独立尝试时。

对于“可嵌套”,您可以拥有一个单独的文档来表示书签列表,每个书签都可以有一个“属于”字段来标识该列表。无论如何,能够拥有多个顶级书签集(Bob 的书签、Jill 的书签等)可能很有用。删除列表或子列表时,需要小心,以确保所有关联的书签也被删除,否则它们将成为孤儿。

可以通过使用描述文档路径的复合键来构建整个书签集,然后使用组级别来检索文档中树的位置。以下代码片段描述了一个文件树,其中文件的路径存储在文档的 "path" 键下

// map function
function(doc) {
  if (doc.type === "file") {
    if (doc.path.substr(-1) === "/") {
      var raw_path = doc.path.slice(0, -1);
    } else {
      var raw_path = doc.path;
    }
    emit (raw_path.split('/'), 1);
  }
}

// reduce
_sum

这将把行输出到表单视图中,形式为 ["opt", "couchdb", "etc", "local.ini"],用于 doc.path/opt/couchdb/etc/local.ini。然后,您可以通过指定 startkey["opt", "couchdb", "etc"]endkey["opt", "couchdb", "etc", {}] 来查询 /opt/couchdb/etc 目录中的文件列表。

4.1.4.3. 方法 3:不可变历史/事件溯源

另一个需要考虑的方法是 事件溯源 或命令日志记录,它在许多 NoSQL 数据库中实现,并在许多 操作转换 系统中使用。

在这个模型中,您不是存储单个书签,而是存储所做更改的记录 - “添加书签”、“更改书签”、“移动书签”、“删除书签”。这些记录以追加方式存储。由于记录永远不会被修改或删除,只会添加,因此永远不会出现复制冲突。

这些记录也可以存储为单个 CouchDB 文档中的数组。复制可能会导致冲突,但在这种情况下,只需合并来自两个数组的元素即可轻松解决。

为了查看完整的书签集,您需要从一个基线集(最初为空)开始,并运行自创建基线以来的所有更改记录;或者您需要维护一个最新的版本,并使用尚未看到的更改来更新它。

在复制时合并来自多个来源的历史记录时需要小心。您可能会根据排序方式获得不同的结果 - 考虑在 B 之前获取所有 A 的更改,在 A 之前获取所有 B 的更改,或交错它们(例如,如果每个更改都有一个时间戳)。

此外,随着时间的推移,使用的存储量可能会无限增长,即使书签集本身很小。可以通过将基线版本向前移动,然后只保留该点之后的更改来控制这一点。但是,需要注意的是,不要将基线版本向前移动得太远,以至于存在在那里的活动副本,这些副本最后一次同步是在该时间之前,因为这可能会导致无法自动解决的冲突。

如果有任何不确定性,最好向用户显示一个提示,以帮助在应用程序本身中合并内容。

4.1.4.4. 方法 4:显式保留历史版本

如果您要保留命令日志历史记录,那么只需保留书签列表本身的旧版本可能会更简单。目的是通过将这些修订版作为单独的文档来破坏 CouchDB 自动清除旧修订版的行为。

您可以保留指向“最新”修订版的指针,并且每个修订版都可以指向其前一个修订版。在复制时,可以通过将每个先前版本进行差异化(实际上是合成命令日志)来回溯到一个共同的祖先来进行合并。

这是诸如 Git 之类的版本控制系统作为常规操作实现的行为,尽管通常是逐行比较文本文件,而不是逐字段比较 JSON 对象。

像 Git 这样的系统会积累任意数量的历史记录(尽管它们会尝试通过打包多个修订版来压缩它,以便只存储它们的差异)。使用 Git,您可以使用“历史记录重写”来删除旧的历史记录,但这可能会阻止合并,因为历史记录没有回溯到足够早的时间。

4.1.5. 使用半透明数据库添加客户端安全性

许多应用程序不需要在服务器上进行厚重的安全层。可以使用少量的加密和单向函数来模糊敏感列或键值对,这种技术通常称为半透明数据库。(参见 描述。)

最简单的解决方案是在客户端使用 SHA-256 等单向函数来混淆名称和密码,然后再存储信息。此解决方案使客户端能够控制数据库中的数据,而无需在数据库上添加一个厚重的层来测试每个事务。一些优点是

  • 只有客户端或知道名称和密码的人才能计算 SHA256 的值并恢复数据。

  • 某些列仍然以明文形式保留,这对计算聚合统计数据有利。

  • SHA256 的计算留给客户端计算机,该计算机通常有空闲的周期。

  • 该系统防止内部人员和可能渗透操作系统或在其上运行的任何工具的攻击者进行服务器端窥探。

存在一些限制

  • 没有 root 密码。如果用户忘记了他们的名称和密码,他们的访问权限将永远消失。这限制了它在可以通过发出新的用户名和密码来继续的数据库中的使用。

半透明数据库 这本书中详细介绍了这种主题的许多变体,包括

  • 使用公钥加密添加后门。

  • 使用隐写术添加第二层。

  • 处理打字错误。

  • 将加密与单向函数混合使用。