设计 NoSQL 数据模型的特点

介绍

设计 NoSQL 数据模型的特点 “你必须尽可能快地跑才能留在原地,
为了到达某个地方,你必须以至少两倍的速度奔跑!”
(c) 爱丽丝梦游仙境

前段时间我被邀请去演讲 分析师 我们公司的主题是设计数据模型,因为长时间(有时长达几年)坐在项目上,我们忽视了 IT 技术世界中我们周围正在发生的事情。 在我们公司(碰巧)很多项目不使用NoSQL数据库(至少目前如此),所以在我的演讲中我使用HBase的例子分别对它们进行了一些关注,并试图将材料的呈现面向那些从未使用过它们的人已经起作用了。 特别是,我使用几年前读过的一个示例来说明数据模型设计的一些特征 在 Amandeep Khurana 的文章“HB ase 架构设计简介”中。 在分析例子时,我比较了解决同一问题的几种方案,以便更好地向观众传达主要思想。

最近,我“无事可做”地问自己一个问题(隔离期间的五月长周末尤其有利于这一点),理论计算与实践的对应程度有多大? 事实上,这篇文章的想法就是这样诞生的。 已经使用 NoSQL 几天的开发人员可能不会从中学到任何新东西(因此可能会立即跳过一半的文章)。 但对于 分析师对于那些还没有密切接触过 NoSQL 的人来说,我认为这对于基本了解 HBase 设计数据模型的功能很有用。

实例分析

在我看来,在开始使用NoSQL数据库之前,你需要仔细思考并权衡利弊。 通常,这个问题很可能可以使用传统的关系 DBMS 来解决。 因此,没有重大原因最好不要使用NoSQL。 如果您仍然决定使用 NoSQL 数据库,那么您应该考虑到这里的设计方法有些不同。 特别是其中一些对于那些以前只处理过关系型 DBMS 的人来说可能是不寻常的(根据我的观察)。 因此,在“关系”世界中,我们通常首先对问题域进行建模,然后在必要时对模型进行非规范化。 在NoSQL中我们 应立即考虑处理数据的预期场景 并首先对数据进行非规范化。 此外,还有许多其他差异,将在下面讨论。

让我们考虑以下“综合”问题,我们将继续研究该问题:

有必要为一些抽象社交网络的用户的好友列表设计一个存储结构。 为了简单起见,我们假设我们所有的联系都是定向的(就像在 Instagram 上,而不是 Linkedin 上一样)。 该结构应该允许您有效地:

  • 回答用户A是否阅读用户B的问题(阅读模式)
  • 允许在用户 B 订阅/取消订阅用户 A 的情况下添加/删除连接(数据更改模板)

当然,解决问题的方法有很多。 在常规的关系数据库中,我们很可能会简单地制作一个关系表(例如,如果我们需要存储一个用户组:家庭、工作等,其中包括这个“朋友”,则可能是典型的),并优化访问速度会增加索引/分区。 决赛桌很可能是这样的:

USER_ID
朋友ID

Vasya
彼佳

Vasya
Оля

在下文中,为了清楚和更好地理解,我将标明名称而不是 ID

就 HBase 而言,我们知道:

  • 可以实现不导致全表扫描的高效搜索 仅通过密钥
    • 事实上,这就是为什么向此类数据库编写许多人熟悉的 SQL 查询是一个坏主意; 从技术上讲,当然,您可以从同一个 Impala 向 HBase 发送带有 Joins 和其他逻辑的 SQL 查询,但效果如何......

因此,我们被迫使用用户ID作为密钥。 我对“在哪里以及如何存储朋友的 ID?”这个主题的第一个想法也许是将它们存储在列中的想法。 这个最明显和“天真的”选项看起来像这样(我们称之为 选项 1(默认)以供进一步参考):

行键
扩音器

Vasya
1:彼佳
2:奥利亚
3:达莎

彼佳
1:玛莎
2:瓦夏

这里,每一行对应一个网络用户。 列的名称为:1, 2, ... - 根据好友的数量,好友的 ID 存储在列中。 需要注意的是,每行都有不同数量的列。 在上图的示例中,一行有三列(1、2和3),第二行只有两列(1和2)——这里我们自己使用了关系数据库没有的两个HBase属性:

  • 动态更改列的组成的能力(添加朋友 -> 添加列,删除朋友 -> 删除列)
  • 不同的行可能有不同的列组成

让我们检查一下我们的结构是否符合任务的要求:

  • 读取数据:为了了解 Vasya 是否订阅了 Olya,我们需要减去 整条线 通过键 RowKey = “Vasya” 并对列值进行排序,直到我们在其中“遇到”Olya。 或者迭代所有列的值,“不满足”Olya并返回答案False;
  • 编辑数据:添加好友:对于类似的任务,我们还需要减去 整条线 使用键 RowKey = “Vasya” 来统计他的朋友总数。 我们需要这个好友总数来确定需要在其中记下新好友 ID 的列号。
  • 更改数据:删除好友:
    • 需要减去 整条线 通过RowKey = “Vasya”键,对列进行排序,找到要删除的好友所在的列;
    • 接下来,删除好友后,我们需要将所有数据“转移”到一列中,以免编号出现“间隙”。

现在让我们评估这些算法的效率如何,我们需要在“条件应用程序”方面实现这些算法,使用 O-象征主义。 让我们将假设的社交网络的规模表示为 n。 那么一个用户最多可以拥有的好友数量是(n-1)。 为了我们的目的,我们可以进一步忽略这个(-1),因为在 O 符号使用的框架内它并不重要。

  • 读取数据:需要减去整行并迭代其限制内的所有列。 这意味着成本的上限估计约为 O(n)
  • 编辑数据:添加好友:要确定朋友的数量,需要迭代该行的所有列,然后插入新列 => O(n)
  • 更改数据:删除好友:
    • 与添加类似 - 您需要遍历限制中的所有列 => O(n)
    • 删除列后,我们需要“移动”它们。 如果你“迎头”实施,那么在限制下你最多需要 (n-1) 次操作。 但在这里以及进一步的实际部分,我们将使用不同的方法,该方法将为固定数量的操作实现“伪移位”——也就是说,无论 n 是多少,都会花费恒定的时间。 与 O(n) 相比,这个常数时间(准确地说是 O(2))可以忽略不计。 方法如下图所示:我们只需将数据从“最后”列复制到我们要删除数据的列,然后删除最后一列:
      设计 NoSQL 数据模型的特点

总的来说,在所有场景中,我们都获得了 O(n) 的渐近计算复杂度。
您可能已经注意到,我们几乎总是必须从数据库中读取整行,并且在三分之二的情况下,只是为了遍历所有列并计算好友总数。 因此,作为优化的尝试,可以添加一个“计数”列,该列存储每个网络用户的好友总数。 在这种情况下,我们不能读取整行来计算好友总数,而只能读取一个“count”列。 最重要的是在操作数据时不要忘记更新“count”。 那。 我们得到改善 选项 2(计数):

行键
扩音器

Vasya
1:彼佳
2:奥利亚
3:达莎
计数:3

彼佳
1:玛莎
2:瓦夏

计数:2

与第一个选项相比:

  • 读取数据:得到“Vasya读Olya吗?”这个问题的答案没有任何改变 => O(n)
  • 编辑数据:添加好友:我们简化了新朋友的插入,因为现在我们不需要读取整行并迭代其列,而只能获取“count”列的值等。 立即确定插入新朋友的列号。 这导致计算复杂度降低至 O(1)
  • 更改数据:删除好友:删除好友时,我们也可以利用这一栏来减少数据向左“平移”一个单元格时的I/O操作次数。 但是仍然需要遍历列来找到需要删除的列,所以 => ​O(n)
  • 另一方面,现在更新数据时,我们每次都需要更新“count”列,但这需要恒定的时间,在O符号的框架内可以忽略不计

总的来说,选项2似乎更优化一些,但它更像是“进化而不是革命”。 为了进行一场“革命”,我们需要 选项 3(栏).
让我们把一切“颠倒过来”:我们将任命 列名 用户 ID! 列本身写什么对我们来说不再重要,让它成为数字1(一般来说,有用的东西可以存储在那里,例如,“家人/朋友/等”组)。 这种方法可能会让没有准备的“外行”感到惊讶,因为他们以前没有使用 NoSQL 数据库的经验,但正是这种方法可以让您在这项任务中更有效地利用 HBase 的潜力:

行键
扩音器

Vasya
彼佳:1
奥利亚:1
大傻:1

彼佳
玛莎:1
瓦夏:1

在这里,我们同时获得了几个优势。 为了理解它们,让我们分析新结构并估计计算复杂度:

  • 读取数据:为了回答 Vasya 是否订阅 Olya 的问题,只需读取一列“Olya”即可:如果存在,则答案为 True,如果不存在 – False => O(1)
  • 编辑数据:添加好友:添加好友:只需添加一个新列“Friend ID” => O(1)
  • 更改数据:删除好友:只需删除好友 ID 列 => O(1)

正如您所看到的,这种存储模型的一个显着优点是,在我们需要的所有场景中,我们只对一列进行操作,避免了从数据库中读取整行,而且还枚举了该行的所有列。 我们可以就此打住,但是……

你可能会感到困惑,并沿着优化性能和减少访问数据库时 I/O 操作的道路走得更远。 如果我们将完整的关系信息直接存储在行键本身中会怎样? 也就是说,将密钥组合为 userID.friendID? 在这种情况下,我们甚至根本不需要读取该行的列(选项4(行)):

行键
扩音器

瓦夏·佩蒂亚
彼佳:1

瓦夏·奥利亚
奥利亚:1

瓦夏·达莎
大傻:1

彼佳·玛莎
玛莎:1

彼佳·瓦夏
瓦夏:1

显然,与之前的版本一样,在这种结构中评估所有数据操作场景的复杂度将为 O(1)。 与选项 3 的区别仅在于数据库中 I/O 操作的效率。

好吧,最后一个“鞠躬”。 很容易看出,在选项4中,行键将具有可变长度,这可能会影响性能(这里我们记得HBase将数据存储为一组字节,表中的行按键排序)。 另外,我们有一个分隔符,在某些情况下可能需要处理。 为了消除这种影响,您可以使用 userID 和friendID 的哈希值,并且由于这两个哈希值都具有恒定的长度,因此您可以简单地将它们连接起来,而无需分隔符。 那么表中的数据将如下所示(选项5(哈希)):

行键
扩音器

dc084ef00e94aef49be885f9b01f51c01918fa783851db0dc1f72f83d33a5994
彼佳:1

dc084ef00e94aef49be885f9b01f51c0f06b7714b5ba522c3cf51328b66fe28a
奥利亚:1

dc084ef00e94aef49be885f9b01f51c00d2c2e5d69df6b238754f650d56c896a
大傻:1

1918fa783851db0dc1f72f83d33a59949ee3309645bd2c0775899fca14f311e1
玛莎:1

1918fa783851db0dc1f72f83d33a5994dc084ef00e94aef49be885f9b01f51c0
瓦夏:1

显然,在我们正在考虑的场景中使用这种结构的算法复杂度将与选项 4 相同,即 O(1)。
总的来说,让我们在一张表中总结我们对计算复杂性的所有估计:

添加好友
检查朋友的情况
删除好友

选项 1(默认)
O(N)
O(N)
O(N)

选项2(计数)
O(1)
O(N)
O(N)

选项 3(栏)
O(1)
O(1)
O(1)

选项 4(行)
O(1)
O(1)
O(1)

选项 5(哈希)
O(1)
O(1)
O(1)

正如您所看到的,选项 3-5 似乎是最可取的,理论上可以确保在恒定时间内执行所有必要的数据操作场景。 在我们的任务条件下,并没有明确要求获取用户所有好友的列表,但在实际的项目活动中,作为优秀的分析师,“预测”可能会出现这样的任务并进行预测是有好处的。 “铺上一根稻草。” 因此,我同情选项 3。但在实际项目中,这个请求很可能已经通过其他方式解决,因此,在没有对整个问题有总体了解的情况下,最好不要这样做最终结论。

实验准备

我想在实践中检验上述理论论证——这是长周末产生的想法的目标。 为此,有必要评估我们的“条件应用程序”在所有描述的使用数据库的场景中的运行速度,以及随着社交网络(n)规模的增加而增加的时间。 我们感兴趣并在实验过程中测量的目标参数是“条件应用”执行一项“业务操作”所花费的时间。 “商业交易”是指以下其中一项:

  • 添加一位新朋友
  • 检查用户A是否是用户B的好友
  • 删除一名好友

因此,考虑到最初声明中概述的要求,验证场景如下:

  • 数据记录。 随机生成大小为 n 的初始网络。 为了更接近“真实世界”,每个用户拥有的好友数量也是一个随机变量。 测量我们的“条件应用程序”将所有生成的数据写入 HBase 的时间。 然后将所得时间除以添加好友的总数——这就是我们得到一次“业务操作”的平均时间的方法
  • 读取数据。 为每个用户创建一个“个性”列表,无论用户是否订阅这些“个性”,您都需要获得答案。 列表的长度=大约用户好友的数量,对于检查的好友中一半的答案应该是“是”,而另一半则是“否”。 检查的执行顺序是交替回答“是”和“否”(也就是说,每隔一种情况,我们就必须检查选项 1 和 2 行的所有列)。 然后将总筛选时间除以接受测试的朋友数量,以获得每个受试者的平均筛选时间。
  • 删除数据。 删除该用户的所有好友。 而且,删除顺序是随机的(也就是说,我们“打乱”用于记录数据的原始列表)。 然后将总检查时间除以删除的好友数量,以获得每次检查的平均时间。

需要针对 5 个数据模型选项中的每一个以及不同规模的社交网络运行这些场景,以了解时间如何随着其增长而变化。 当然,在 5 个 n 内,所有 XNUMX 个选项的网络连接和要检查的用户列表必须相同。
为了更好地理解,下面是 n= 5 时生成数据的示例。编写的“生成器”生成三个 ID 字典作为输出:

  • 第一个用于插入
  • 第二个用于检查
  • 第三——删除

{0: [1], 1: [4, 5, 3, 2, 1], 2: [1, 2], 3: [2, 4, 1, 5, 3], 4: [2, 1]} # всего 15 друзей

{0: [1, 10800], 1: [5, 10800, 2, 10801, 4, 10802], 2: [1, 10800], 3: [3, 10800, 1, 10801, 5, 10802], 4: [2, 10800]} # всего 18 проверяемых субъектов

{0: [1], 1: [1, 3, 2, 5, 4], 2: [1, 2], 3: [4, 1, 2, 3, 5], 4: [1, 2]} # всего 15 друзей

可以看到,字典中所有大于 10 的 ID 都可以检查,这正是那些肯定会给出答案 False 的 ID。 “好友”的插入、检查和删除完全按照字典中规定的顺序进行。

该实验是在一台运行 Windows 10 的笔记本电脑上进行的,其中 HBase 在一个 Docker 容器中运行,Python 和 Jupyter Notebook 在另一个容器中运行。 Docker 被分配了 2 个 CPU 核心和 2 GB RAM。 所有逻辑,例如“条件应用程序”的模拟以及用于生成测试数据和测量时间的“管道”,都是用 Python 编写的。 该库用于与 HBase 配合使用 快乐基地,计算选项 5 的哈希值 (MD5) - hashlib

考虑到特定笔记本电脑的计算能力,通过实验选择了 n = 10, 30, … 的启动。 170 – 当整个测试周期的总操作时间(所有 n 的所有选项的所有场景)或多或少合理并且适合一次茶会期间(平均 15 分钟)。

这里有必要指出的是,在这个实验中我们主要不是评估绝对的性能数据。 即使是不同两个选项的相对比较也可能不完全正确。 现在我们感兴趣的是时间随 n 变化的性质,因为考虑到“测试台”的上述配置,很难获得“清除”随机和其他因素影响的时间估计(并且没有设置这样的任务)。

实验结果

第一个测试是填写好友列表所花费的时间如何变化。 结果如下图所示。
设计 NoSQL 数据模型的特点
正如预期的那样,选项 3-5 显示了几乎恒定的“业务交易”时间,该时间不依赖于网络规模的增长和无法区分的性能差异。
选项 2 也显示出稳定但稍差的性能,几乎是选项 2-3 的 5 倍。 这不能不让人高兴,因为它与理论相关——在这个版本中,HBase 的 I/O 操作数量恰好是原来的 2 倍。 这可以作为我们的测试台原则上提供良好准确性的间接证据。
正如预期的那样,选项 1 也是最慢的,并且表明将另一个选项添加到网络大小所花费的时间呈线性增加。
现在让我们看看第二次测试的结果。
设计 NoSQL 数据模型的特点
选项 3-5 再次按预期运行 - 恒定时间,与网络大小无关。 选项 1 和 2 展示了随着网络规模的增加而线性增加的时间以及类似的性能。 此外,选项 2 被证明有点慢 - 显然是因为需要校对和处理额外的“计数”列,随着 n 的增长,这一列变得更加明显。 但我仍然不会下任何结论,因为这种比较的准确性相对较低。 此外,这些比率(选项 1 或 2 更快)在每次运行中都会发生变化(同时保持依赖性和“并驾齐驱”的性质)。

好吧,最后一张图是去除测试的结果。

设计 NoSQL 数据模型的特点

同样,这里没有什么惊喜。 选项 3-5 在恒定时间内执行删除。
此外,有趣的是,与前面的场景不同,选项 4 和 5 的性能明显比选项 3 稍差。显然,行删除操作比列删除操作更昂贵,这通常是合乎逻辑的。

正如预期的那样,选项 1 和 2 表现出时间的线性增加。 同时,由于“维护”计数列需要额外的 I/O 操作,选项 2 始终比选项 1 慢。

实验的一般结论:

  • 选项 3-5 展示了更高的效率,因为它们利用了 HBase; 此外,它们的性能彼此之间存在常数差异,并且不依赖于网络的大小。
  • 没有记录选项 4 和 5 之间的差异。 但这并不意味着不应该使用选项5。 考虑到测试台的性能特征,所使用的实验场景很可能不允许它被检测到。
  • 利用数据执行“业务操作”所需时间增加的性质总体上证实了之前获得的所有选项的理论计算。

结语

进行的粗略实验不应被视为绝对真理。 有许多因素没有考虑在内,导致结果失真(这些波动在网络规模较小的图中尤其明显)。 比如happybase使用的thrift的速度,我用Python写的逻辑的实现量和方法(我不能说代码是最优编写的,有效利用了所有组件的能力),也许HBase 缓存的功能、笔记本电脑上 Windows 10 的后台活动等。 一般来说,我们可以假设所有理论计算都已通过实验证明了其有效性。 好吧,或者至少是无法用这样的“正面攻击”来反驳他们。

总之,给刚开始在 HBase 中设计数据模型的每个人的建议:从以前使用关系数据库的经验中摘要并记住“命令”:

  • 在设计时,我们从数据操作的任务和模式出发,而不是从领域模型出发
  • 高效访问(无需全表扫描)——仅通过键
  • 非规范化
  • 不同的行可以包含不同的列
  • 扬声器的动态组成

来源: habr.com

添加评论