4.1. 文件设计注意事项

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

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

而CouchDB将为 _id 在大多数情况下,您最好自己生成,原因如下:

  • 如果因为任何原因你错过了 200 OK 从CouchDB回复,并且再次尝试存储文档,您将得到存储在multiple下的相同文档内容 _id 如果中间代理和缓存系统不通知开发人员正在重试失败的事务,则很容易发生这种情况。
  • _id s是CouchDB中唯一的强制值,所以您最好利用它。CouchDB将其文档存储在B+树中。每个附加或更新的文档都存储为一个叶节点,可能需要重新编写中间节点和父节点。如果您可以自己安排顺序,那么您可能能够更有效地利用排序您自己的id,而不是自动生成的id。

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

由于复制以及CouchDB的分布式特性,在CouchDB中使用自递增序列是不实际的。它们通常用于确保数据库表中每一行的唯一标识符。CouchDB自己生成惟一的id,您也可以指定自己的id,所以您不需要在这里使用序列。如果您将一个序列用于其他东西,那么您最好找到另一种方法,在CouchDB中以另一种方式表达它。

4.1.3. 预聚合数据

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

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

稍后,您可以进一步 decimate 通过遍历整个数据库并生成文档以较低的粒度(例如,每天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":"http://couchdb.apache.org/"}
  ]}
]

然后,它可以通过遍历这个结构来显示书签菜单和子菜单。

现在考虑这个场景:用户在自己的电脑上有一组书签,然后将其复制到她的笔记本电脑上。在笔记本电脑上,她将新闻链接改为指向CNN,将“Register”重命名为“Register”,并在其后添加了一个新的slashdot链接。在桌面上,她的丈夫删除了天气链接,并在Tech文件夹中添加了一个新的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":"http://couchdb.apache.org/"}
  ]}
]

而且电脑有:

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

在下一次同步时,我们希望预期的合并发生。也就是说,在一方被更改、添加或删除的链接在另一方也会被更改、添加或删除,除非绝对必要,否则不需要人为干预。

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

下面所有允许自动合并更改的方法都依赖于具有某种历史记录,回到复制副本的发散点。

CouchDB本身并没有为此提供机制。它为一个文档存储任意数量的旧的∗id(trunk现在有了一个修剪id历史的机制),以便复制。但是,除了文档本身不存在冲突的情况外,文档的版本不会被压缩。

Do not rely on the CouchDB revision history mechanism to help you build an application-level version history. 它的唯一目的是确保数据库之间的最终一致复制。由您自己来明确地以任何对应用程序有意义的形式维护历史记录,并对其进行修剪以避免过度的存储利用率,同时不要修剪超过实时复制副本最后分离的时间点。

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的索引的平均值。不幸的是,这将在一段时间后失败,当你用完了精度,用户将困惑地发现,他们最近的书签不再记得他们的确切位置放置。

更好的方法是保留索引的字符串表示,它可以随着树的细分而增长。这不会遇到上述问题,但可能会导致该字符串在很长时间后变得任意。他们可以重新编号,但重新编号的行动可能会带来很多冲突,特别是如果双方单独尝试的话。

对于“nestable”,您可以有一个表示书签列表的单独文档,每个书签可以有一个标识该列表的“属于”字段。不管怎样,拥有多个顶级书签集(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 . 然后可以在 /opt/couchdb/etc 通过指定 startkey 属于 ["opt", "couchdb", "etc"] 和一个 endkey 属于 ["opt", "couchdb", "etc", {{}}] .

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

另一种考虑的方法是 Event Sourcing 或命令日志记录,在许多NoSQL数据库中实现并在许多 operational transformation 系统。

在这个模型中,不是存储单个书签,而是存储所做更改的记录-“Bookmark added”、“Bookmark changed”、“Bookmark moved”、“Bookmark deleted”。它们以仅附加的方式存储。因为记录不会被修改或删除,只会添加到,所以不会有任何复制冲突。

这些记录也可以作为数组存储在单个CouchDB文档中。复制可能会导致冲突,但在这种情况下,只需将两个阵列中的元素组合起来就可以很容易地解决问题。

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

在将来自多个源的历史记录合并到一起时,需要在复制后小心。你可能会得到不同的结果,这取决于你对它们的排序方式——考虑把所有A的更改放在B之前,把所有B的更改放在A之前,或者将它们交错(例如,如果每个更改都有时间戳)。

而且,随着时间的推移,使用的存储量可以任意增大,即使书签集本身很小。这可以通过向前移动基线版本,然后只保留该点之后的更改来控制。但是,需要注意的是,不要将基线版本向前移动到一定程度,否则会有活动副本在该时间之前最后同步,因为这可能会导致无法自动解决的冲突。

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

4.1.4.4. 方法4:明确保留历史版本

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

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

这是修订控制系统的一种行为,例如 Git 尽管通常逐行比较文本文件,而不是逐个字段地比较JSON对象,但作为一个例程来实现。

像Git这样的系统将任意累积大量的历史记录(尽管他们会尝试通过打包多个修订来压缩它,以便只存储它们的差异)。使用Git,您可以使用“历史重写”来删除旧的历史记录,但是如果历史不能追溯到足够远的时间,这可能会禁止合并。

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

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

最简单的解决方案是在客户机上使用SHA-256这样的单向函数,在存储信息之前对名称和密码进行置乱。这种解决方案使客户机能够控制数据库中的数据,而不需要在数据库上使用一层厚厚的层来测试每个事务。其优点包括:

  • 只有客户机或知道名称和密码的人才能计算SHA256的值并恢复数据。
  • 有些列仍然不清楚,这是计算聚合统计数据的一个优势。
  • SHA256的计算留给客户端计算机,客户端计算机通常有空闲的周期。
  • 该系统可防止内部人员和任何可能穿透操作系统或其上运行的任何工具的攻击者进行服务器端窥探。

有一些限制:

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

这本书对这个主题有许多不同的描述 Translucent Databases ,包括:

  • 添加带有公钥加密的后门。
  • 使用隐写术添加第二层。
  • 处理印刷错误。
  • 将加密与单向函数混合。