The content of this page is under Translations.

[译]非范式化实现非关系型(NoSQL)数据库的 JOIN 操作之三:确保一致性

第一第二部分中,为了在 NoSQL 的“对一”关系上模拟(传统数据库的) JOIN 操作,我们介绍了非范式化,物化视图以及后台任务的概念。本文将叙述整个谜团中尚未揭示的琐碎却重要的细节,以及如何保证该方法在诸如服务器当机这类情况下正常实施。

什么时候启用后台任务最好?

首先,回顾一下当前的情形:

  • 一个物化视图(PhotoUserView 类型),包含非范式化后用户的一些属性(例如性别)以及照片的一些属性(例如流行程度)。该物化视图使用来模拟“对一”方向的 JOIN 操作的。PhotoUserView 的事例必须保证得到持续更新。
  • 一旦用户编辑了照片的属性,就要启用一个后台任务来更新所有对应 PhotoUserView 实体的属性。
  • 加入用户改变其性别(或者头发颜色),后台任务要更新该用户所有照片对应的 PhotoUserView 中非范式化的属性(性别,头发颜色等)。

我们面临的问题是:什么是启用后台任务的最佳时机,尤其是考虑到网络连接/数据库/服务器随时有崩溃可能的前提下?最直接的想法是紧接在保存了对用户或照片的更改之后(比如利用 Django 的 post_save 信号)。然而这个方案实际问题重重。下面这个编辑现存照片的情形就很讨厌:

  • 用户编辑照片
  • 对应的 submit_view 保存了用户作出的更改
  • 此时Web服务崩溃了

这里用户对他的某一张照片作出的修改成功得以保存,不过更新对应物化视图用的后台任务却没有展开。物化视图里包含的属性过时了,(类似[上一篇]中那样)利用它进行的查询将无法返回对应的照片。

save()结束之后再调用后台任务还有一个问题:后台任务和save()有时会在更新的时候产生冲突。下面举个例子进一步说明:

Background tasks conflicts

  • t1时刻,一次对某照片的修改开始进行保存(比如用户提交修改之后)。
  • t2时刻,后台任务1随着前面的修改开始运行。
  • 后台任务1 从数据库获取非范式化后的数据(照片及用户)。由于种种原因这个过程中发生了一些延迟。
  • t3时刻,同一张照片再次得到修改。
  • t4时刻,后台任务2开始运行,并且在后台任务1之前完成结束运行。
  • 后台任务1此时才对物化视图内容进行更新,然而它使用的却是已经过时的数据,结果是覆盖了后台任务2前面做出的修改。

由于第一个后台任务在第二个后台任务保存起结果之前获取了数据,物化视图里最终存储了过时的数据。当 save() 加第二个后台任务所花的时间少于允许后台任务时间最大值的时候,重叠的后台任务就可能发生,导致这个问题经常性的出现。

一个潜在的解决办法是在 save() 之前启用后台任务,增加一个足够长的延迟,让物化视图的更新能够完成(获取+保存数据所需的最长时间)。在 App Engine 一个请求的处理时间不能超过 30 秒。这可以确保后台任务在保存进程结束之前得以运行,这样后台任务和另一个 save() 开启的重合任务重叠的现象就不会发生,从而解决了更新冲突。另外 save() 之后的崩溃不会阻止物化视图的更新,因为后台任务已经在此之前启动了。后台任务将通过主键从数据库获取照片,然后更新它对应的物化视图。

Background tasks  with big delays

长时间延迟的使用避免了更新冲突,保证了更新的正常进行。

几乎在同时开始对照片进行多次修改也不会对更新物化视图带来问题。因为该操作所在的后台任务拥有自己的延迟。他们总能从数据库得到最新版本的照片,然后对其物化视图进行更新。

除此之外,假如数据库在保存用户修改内容之前,开启后台任务之后崩溃了,后台任务依旧可以执行,更新的物化视图将和原来的内容保持一致。所以这个情况也不用担心。

插入什么的怎么办呢?

现在考虑一下用户创建新照片的情形。由于后台任务需要在save()之前启动,照片的主键尚且是未知的——后台任务也就没办法知道照片的主键。一种方案是用 UUID 作为新照片的标记。对应的后台任务以这个UUID来获取新建立的照片并且创建对应的物化视图。

如何更新物化视图

至此我们讨论了启用后台任务的时机,但还没有考虑如何更新物化视图。 每一次的更新需要保证影响到的物化视图得到全新的重建(同时不该更新的物化视图保持原样),否则我们会获得错误的更新结果。现在假设用户改变性别只会更新非范式化后对应的物化视图中的性别属性,改变照片的标题也只改变对应物化视图的标题属性。我们可能会面临如下情形:

  • 用户的性别(或者头发颜色)改变
  • 照片标题改变
  • 后台任务1开启并且只从数据库提取该用户及其物化视图(来更新 denormalized_gender)
  • 后台任务2开启并且只提取该照片以及和之前相同的物化视图(来更新 denormalized_title)
  • 后台任务1保存对物化视图的更新(denormalized_gender 的新值)
  • 后台任务2保存对物化视图的更新(denormalized_title 的新值),覆盖了后台任务1做出的改变(denormalized_gender 回到原来的值)

结果是后台任务2在后台任务1保存改变之前从数据库获取了信息,所以引起物化视图的最终值没有的到正确的更新。为了避免这种情况,我们必须彻底,彻底的重写整个涉及到的物化视图。即使改变的只是照片标题,denormalized_gender 仍然需要更新——对应的照片和用户数据也要同时被提取出来!这样每次更新所有属性的行为可以确保即使重写冲突发生也不会导致错误结果。

两全其美的办法

设定很长的延迟可能会影响用户体验。比如用户可能修改了某张照片却不能立刻看 到结果。聪明的你可能会想到:或许应该在保存实力之后也开启一个后台任务?没 错,这和笔者的建议不谋而合。在实力保存之后立即启用后台任务保证了更新的及 时性,使最新的物化视图立即出现在下一次查询中。在实例保存之前的后台任务则 能保证任务执行的完整性以及物化视图正确更新。

不过值得注意的是在保存之后启用的后台任务回产生前文描述过的冲突。那还是得 放弃 save() 之后的后台任务喽?不,因为 save() 之前的后台任务还能起到修补 物化视图错误的作用。

Background task conflict solved

即便之前的后台任务遇到了更新冲突,"纠错后台任务"仍然可以提取最新的用户 和照片信息从而正确的更新物化视图。

如果纠错任务和第三个个更新操作发生了相同的冲突,这次更新还是能通过它自身 的延迟后台任务来纠错。所以最差我们得到错误的物化视图,然后很快纠正;最佳 情形是物化视图马上获得更新。重点是,物化视图无论如何最终都是正确的。

说到底,实际操作如下:

  • 为保存实例之前启用的后台任务预留足够的延迟。同时具备纠正物化视图保存 错误的作用。

  • 在保存实例之后启用后台任务确保物化视图得到即时更新。

系列总结

本系列的三篇文章中我们描述了在非关系型数据库中"对一"的 JOIN 操作的一种方案。最重要的几点如下:

  • 通过非范式化新建一个中间类型(物化视图)来进行 JOIN 操作。
  • 操作结果具备一致性。
  • 数据储存量加倍(因为多了物化视图),但储存价格低廉。
  • 如果非范式化的属性值改变,则需要后台任务。

该方案可以应用在 App Engine 和 MongoDB (或许需要 celery 辅助)以及其他 NoSQL 数据库。

不难发现,在非关系型数据库搞 JOIN 操作是一坨混乱。丛开发者的视角来看,以 下几点证明它不是最佳选择:

  • 不得不手工为所有需要 JOIN 操作的类型维护物化视图。
  • 不得不从多出来的类型(物化视图)上考虑查询和使用非范式化。
  • 物化视图增加了不同类型之间的依赖性,降低代码可重用性。
  • 随之而来的是引入更多 bug。降低了生产效率,增加了 coding 时的痛苦。

那么在需要 JOIN 的时候,有没有比手工实现这一切更好的办法呢?我们有一个优 雅的答案:django-dbindexer。它可以使用本系列中介绍的原理帮你进行 JOIN 操 作,省去你重构查询或者为数据建立物化视图的麻烦!只要告诉 django- dbindexer 你需要哪些类型的 JOIN,它会帮你解决剩下的工作。

本文是本系列中的最终篇,紧接已经翻译完成的第一篇第二篇

本文作者为Thomas Wanschik,译者为DaNmarner。原文载于 http://www.allbuttonspressed.com/blog/django/2010/10/JOINs-via-denormalization-for-NoSQL-coders-Part-3-Ensuring-consistency ,译文原载于 http://blog.danmarner.com/me/entry/joins-via-denomalization-for-nosql-part3/ 。转载请著名出处,保留链接。

[译]非范式化实现非关系型(NoSQL)数据库的 JOIN 操作之二:物化视图

DaNmarner按:本系列的(英语)原文载于 http://www.allbuttonspressed.com 。该站由若干博客以及项目聚合而成,其中最著名的就是为 Django 提供 NoSQL 数据库支持的 django-nonrel 项目。该系列内容中的示例代码因此也使用 Django 的 API 写成。

第一部分 中我们介绍了当数据关系中“对一”方向的属性不变时,使用非范式化来绕过非关系型数据库中 JOIN 操作的办法。本文将介绍一个当“对一”方向数据中包含可变属性时实现 JOIN 操作的办法。

回顾一下目前的情形:

  1. 现在有User(用户,“对一”方向)及他们的 Photo(照片,“对多”方向)。
  2. Photo 包含了它 User 的 'gender(性别)' 属性来绕过根据性别查询照片时需要的 JOIN 操作。

很明显,在“对一”方向改变属性值的关键在于更新非范式化保留在“对多”方向类型上的对应数据。也就是说每次用户变性(或者改变头发颜色)的时候,该用户的每一张照片上通过非范式化储存的性别信息也要相应改变。当用户有成百上千的照片时这是一个困难的任务,因为这个规模的更新花掉的时间太多。我们需要一个可扩展的方案来进行更新。

后台任务来也

一种可能的方案是每当用户改变它性别的时候就启用一个后台任务。显然该方案可能带来一致性问题——修改不会立即就看到结果。但大多数情况下这是可以接受的,而且通常后台任务执行起来都是灰常快的。

用后台任务来更新一个用户的所有照片并不像看起来那么简单。魔鬼在于细节。假设一个后台任务正在尝试更新一张照片(比如某些非范式化的用户属性),与此同时用户刚巧在编辑同一照片的某些属性,比如照片的标题。这种情形可能导致后台任务最终覆盖了用户的操作(后者相反)。下图以用户修改照片的标题为例:

Update conflicts

  • 后台任务从数据库提取一张照片来更新上面的性别信息(任务保留照片版本A)
  • submit_view 视图提取统一张照片来更改它的标题(视图也保留照片版本A)
  • 后台任务保存照片(照片版本B得到保存,非范式化的性别属性得到更新)
  • submit_view 完成非范式化引起的更新并且保存照片(照片版本C得到保存,标题得到更新)

问题是版本C没有包含后台任务作出的改变——非范式化的性别(版本B的内容),因为 submit_view 提取照片(版本A)的时候后台任务还没来得及保存它对性别属性作出的修改。所以 submit_view 不知道后台任务作出的修改所以直接覆盖了它。

数据库事务(transaction)可以解决这个问题。然而我们不得不同时在后台任务和保存过程中都用到事务来避免这个问题。这样做会导致高流量的站点速度下降,开发人员又必须记住每次更新照片的时候都要使用事务。另外 Django 没有对事务作出优化所以我们又得记得使用 QuerySet.update()。况且很少非关系型数据库支持优化事务或者原子更新(atomic UPDATE )操作。

物化视图(Materializd Views)

另一个方案是引入一个包含与Photo一对一关系的第三个model。它的主要思路是分离查询用到的信息(照片的属性,比如主人的性别)和查询的目标中实际包含的信息(照片本身):::

class PhotoUserView(models.model):
    # denormalize all data of the photo
    denormalized_photo_title = models.CharField(max_length=100)
    denormalized_photo_popularity = models.CharField(max_length=100)
    denormalized_photo_user = models.ForeignKey(User)
    ....
    # copy the user's gender into denormalized_gender
    denormalized_user_gender = models.CharField(max_length=10)
    # one-to-one relation used as primary key
    photo = models.OneToOneField(Photo, primary_key=True)

可见所有Photo的属性都通过非范式化移入了PhotoUserView,用户的性别也包含在 内。多出来的这个实体导致每张照片的数据储存加倍,但当下的储存空间价格低 廉,所以不会带来大问题。

materialized view

PhotoUserView当然也指向User,因为它包含了photo经过非范式化后得到的 denormalized_user外键。上图有意省略了这个链接,因为它无助于我们对物化视 图概念的理解。

有了这个model我们就能高效的执行这个以前只能通过JOIN才能完成的查询了:::

photo_pks = PhotoUserView.objects.filter(
    denormalized_user_gender='female',
    denormalized_photo_popularity='high'
    ).values('pk')
female_user_photos = Photo.objects.filter(pk__in=photo_pks)

以上的窍门在于利用PhotoUserView的一对一关系找到所有符合条件照片的主键, 然后用它们来提取对应的照片。这项技巧类似于关系索引(relation index) (见 Building Scalable, Complex Apps on App Engine, 类似技巧也可以 在 nonrel-ysearch 中找到。另外在 App Engine 以及几乎所有其它 NoSQL 数据库里 pk__in 过滤可以高效的批量获取所有用户用不到 JOIN 的查询仍然可以 在 Photo 上直接进行。

物化视图的一个重要优势在于解决了用户和后台任务同时编辑照片的冲突。我们进 一步看看这个现象:如果用户改变了ta的性别,相应的后台任务需要更新的是经过 非范式化的PhotoUserView上的gender属性,而不是Photo上的。所以当用户在同一 时刻编辑照片的时候就不会遇到和后台任务的冲突了(前者编辑的是Photo)。

然而非范式化后,为了保持从 Photo 转移到到 PhotoUserView 的所有属性得到更新,修改照片属性本身也要在后台任务进行了。

所以最终的结果是:

  • 用户修改照片的属性 => 调用一个后台任务来更新应 PhotoUserView 实体里非范式化得到的对应属性。

  • 用户改变它的性别 => 调用一个后台任务来更新所有指向该用户的 PhotoUserView 实体里非范式化得到的性别属性。

有人会反对说后台任务消耗太多的带宽,MapReduce 则相对更加高效。然而除了 CouchDB 的视图之外,多数 MapReduce 的实现都不能进行微操作。本文中介绍的物化视图只更新相关的实体。对比之下,类似 MongoDB 中的 MapReduce 实现将对整个数据库 index 进行重建,这对于大型站点来说意味着更多的资源消耗。假如使用的数据库支持事务或者原子更新,那么你可以放弃物化视图来进行优化,然而届时写代码时必须严格保证 事务/QuerySet 使用在对应的位置。而当你需要重用某个 Django 应用的时候这一点又会带来新的问题。大多数应用使用的都是不够安全的 Model.save()。物化视图则没有这些缺点。

本系列的下一篇将介绍如何通过在恰当的时间执行后台任务来避免更新冲突,以及如何处理数据库崩溃这种操蛋的情况。

本文是本系列中的第二篇,紧接已经翻译完成的第一篇 更多内容见第三篇

本文作者为Thomas Wanschik,译者为DaNmarner。原文载于 http://www.allbuttonspressed.com/blog/django/2010/09/JOINs-via-denormalization-for-NoSQL-coders-Part-2-Materialized-views ,译文原载于 http://blog.danmarner.com/me/entry/joins-via-denomalization-for-nosql-part2/ 。转载请著名出处,保留链接。

[译]非范式化实现非关系型(NoSQL)数据库的 JOIN 操作之一:引入

DaNmarner按:本系列的(英语)原文载于 http://www.allbuttonspressed.com 。该站由若干博客以及项目聚合而成,其中最著名的就是为 Django 提供 NoSQL 数据库支持的 django-nonrel 项目。该系列内容中的示例代码因此也使用 Django 的 API 写成。要明白文章讨论的内容,读者只要要有如下背景知识:

  1. 理解关系型数据库中 JOIN 操作,以及规范化(Nomalization)的概念。
  2. 对非关系型(NoSQL)数据库的数据储存模型有初步的体会,至少知道 JOIN 操作是不可能实现的。
  3. 会基本的 Django 数据查询操作。

App Engine 和 MongoDB 这类非关系型 (non-relational) 数据库不支持关系型数据库的 JOIN 操作,因为它限制了数据库的可扩展性。然而在特定的情形下这个操作又是难以避免的。本系列就如何在保持可扩展性的同时在 NoSQL 类型数据库中使用 JOIN 作出解答,顺便介绍一些有用的 NoSQL 代码编写技巧。这里是系列文章的第一篇,介绍”多对一“情形。

JOIN 到底有什么用?

我们以用户和照片作为例子。用户代表了数据的”对一“部分,照片则是”对多“的部分:

User-Photo one-to-many data relation

用户查找其他用户的照片是一个很常见的情形。虽然查找某个用户的照片在非关系型数据库中是轻而易举的事情:::

Photo.objects.filter(user=some_user)

然而类似:::

Photo.objects.filter(user__gender='female', popularity='high')

这样按照某些特定条件查找用户的照片就没那么简单了。很明显上面用到了 JOIN (NoSQL 数据库不支持!),因为过滤条件的时候跨入了第二个Model的域。一个显而易见的解决方案是先找出所有的female用户,然后找出指向这些用户同时又符合pupularity条件的照片:::

user_pks = User.objects.filter(gender='female').values('pk')
female_user_photos = Photo.objects.filter(user__in=user_pks, popularity='high')

这里只查找用户的主键是一个避免从数据酷中调出整个用户数据的小优化。不过这个方案带来了一个扩展性问题。下面的情形可以展示这个问题:

  • 一个高流量的站
  • 需要在每一个结果页面展示20张流行的照片
  • 但只有1/5000的用户有符合条件的照片。

这种情况下我们被迫从数据库里获取了过多用户的数据。由于每次都不得不从数据库抽取太多数据,可扩展性已经受限了。另外整个站还变的非常慢,数据库会过载。在 App Engine 则会遇到超时等问题。

非范式化是王道

另外一个解决方案是把用户的性别信息非范式化,放入照片的model里,像这样:::

class Photo(models.model):
    ....
    # copy the user's gender into denormalized_gender
    denormalized_gender = models.CharField(max_length=10)
    ...

然后在每次创建新照片实例的时候加入对应用户的性别信息:::

new_photo = Photo(user=some_user, denormalized_gender=some_user.gender)
new_photo.save()

这样一来查找female用户popular照片就变成了一个在非范式化的性别信息上进行过滤的简单操作:::

Photo.objects.filter(denormalized_gender='female', popularity='high')

可万一我们又需要根据性别之外其他的用户属性来查找照片数据了呢?比如用户的年龄。把用户的年龄非范式化到照片的model里,搞定。

好了,现在我们保住了可扩展性的同时又绕过了缺少 JOIN 的问题,前提是用户的性别是不变的。然而值得注意的是性别也不是一成不变的(-_-!!)。(如果你不同意,不妨把例子中的性别换成头发颜色。)这时查找的结果会出错,因为非规范化使得照片model里储存的信息过时了。下回我会介绍如何处理这样的情形。

本文是本系列三篇文章中的第一篇,第二篇第三篇也已经翻译完毕。

本文作者为Thomas Wanschik,译者为DaNmarner。原文初见于 http://www.allbuttonspressed.com/blog/django/2010/09/JOINs-via-denormalization-for-NoSQL-coders-Part-1-Intro , 译文初见于 http://blog.danmarner.com/me/entry/joins-via-denomalization-for-nosql-part1 ,转载请注明出处,保留连接。