Django:我该如何防止数据库条目的并发修改?

85

有没有办法防止两个或多个用户同时修改同一数据库条目?

向执行第二次提交/保存操作的用户显示错误消息是可以接受的,但不应该默默地覆盖数据。

我认为锁定条目不是一个选项,因为用户可能使用“返回”按钮或简单地关闭浏览器,永远留下锁定。


4
如果一个对象可以被多个并发用户更新,那么你可能存在更大的设计问题。值得考虑的是使用特定于用户的资源或将处理步骤分开到单独的表中,以防止这成为一个问题。 - S.Lott
10个回答

49

这是我在Django中使用乐观锁的方法:

updated = Entry.objects.filter(Q(id=e.id) && Q(version=e.version))\
          .update(updated_field=new_value, version=e.version+1)
if not updated:
    raise ConcurrentModificationException()
上面列出的代码可以被实现为自定义管理器中的一种方法。
我做出以下假设:
  • filter().update()会生成一个单一的数据库查询,因为filter是惰性的
  • 数据库查询是原子的
这些假设足以确保在此之前没有其他人更新过该条目。如果通过此方式更新多行,则应使用事务。 警告Django文档

请注意,update()方法直接转换为SQL语句。 它是用于直接更新的批量操作。 它不运行任何save()方法,也不发出pre_save或post_save信号。


12
好的!不过那应该是使用 '&' 而不是 '&&',对吗? - Giles Thomas
1
你能否通过将对“update”方法的调用放在自己重写的save()方法内来避免“update”不运行save()方法的问题? - Jonathan Hartley
1
当两个线程同时调用filter时会发生什么情况?它们都会收到一个未修改的相同列表e,然后同时调用update。我没有看到任何阻止filterupdate同时进行的信号量。编辑:哦,我现在理解了惰性过滤器。但是假设update()是原子的有效性是什么?肯定数据库处理并发访问。 - totowtwo
2
@totowtwo ACID中的I保证顺序(http://en.wikipedia.org/wiki/ACID)。如果针对与并发SELECT相关的数据执行UPDATE(但稍后启动),它将阻塞直到UPDATE完成。然而,可以同时执行多个SELECT。 - Kit Sunde
1
看起来这只能在自动提交模式下正常工作(这是默认设置)。否则,最终的COMMIT将与此更新SQL语句分开,因此并发代码可以在它们之间运行。而且我们在Django中有ReadCommited隔离级别,所以它会读取旧版本。(为什么我想在这里使用手动事务-因为我想在此更新的同时在另一个表中创建一行。)虽然是个好主意。 - Alex Lokk
也可以使用一种混合方法,结合乐观并发和悲观并发,通过在“读取阶段”中执行逻辑,检查数据库行是否未更改,然后仅在“写入阶段”获取锁定,以兼顾两者的优点。更多详细信息请参考:https://github.com/pirate/django-concurrency-talk - Nick Sweeting

44

这个问题有点老了,我的回答也晚了一些,但根据我的理解,这个问题在Django 1.4中 已经得到修复,具体方法如下:

select_for_update(nowait=True)

请参阅文档

返回一个查询集,它将锁定行直到事务结束,在支持的数据库上生成SELECT ... FOR UPDATE SQL语句。

通常情况下,如果另一个事务已经锁定了所选行中的一行,则查询将阻塞,直到锁释放。如果这不是您想要的行为,请调用select_for_update(nowait=True)。这将使调用非阻塞。如果另一个事务已经获取了冲突的锁,则在查询集求值时会引发DatabaseError异常。

当然,这只在后端支持“select for update”功能时才有效,例如sqlite并不支持。不幸的是:nowait=True在MySql中不被支持,您需要使用nowait=False,这将仅在锁被释放之前阻塞。


2
这不是一个很好的答案 - 问题明确不想要(悲观)锁定,而目前排名较高的两个答案都集中在乐观并发控制(“乐观锁定”)上。但在其他情况下,选择更新是可以的。 - RichVel
@giZm0,这仍然是悲观锁定。第一个获取锁的线程可以无限期地持有它。 - knaperek
7
我喜欢这个答案,因为它是 Django 文档的内容,而不是第三方的美化发明。 - anizzomc

31

实际上,在这里交易并没有帮助太多...除非你想要在多个HTTP请求之间运行事务(你可能不想这样做)。

在这种情况下,我们通常使用"乐观锁定"。据我所知,Django ORM不支持该功能。但是已经有一些关于添加此功能的讨论。

因此,您需要自己解决。基本上,您应该在模型中添加一个"版本"字段,并将其作为隐藏字段传递给用户。更新的正常周期如下:

  1. 读取数据并向用户显示
  2. 用户修改数据
  3. 用户发布数据
  4. 应用程序将其保存回数据库。

要实现乐观锁定,保存数据时,请检查从用户获取的版本是否与数据库中的版本相同,然后更新数据库并增加版本。如果它们不同,则表示自加载数据以来发生了更改。

您可以使用类似以下内容的单个SQL调用来执行此操作:

UPDATE ... WHERE version = 'version_from_user';

仅当版本仍然相同时,此调用才会更新数据库。


1
这个问题也在Slashdot上出现过。你建议的乐观锁定也在那里提出,但在我看来解释得更好一些:http://hardware.slashdot.org/comments.pl?sid=1381511&cid=29536367 - hopla
5
请注意,您确实需要在此基础上使用事务以避免此情况:http://hardware.slashdot.org/comments.pl?sid=1381511&cid=29536613Django提供了中间件,可自动将对数据库的每个操作包装在事务中,从初始请求开始,在成功响应后才提交:http://docs.djangoproject.com/en/dev/topics/db/transactions/(请注意:事务中间件仅有助于避免乐观锁定的上述问题,它本身不提供锁定功能) - hopla
我也在寻找如何做到这一点的详细信息,但迄今为止没有成功。 - seanyboy
1
你可以使用Django批量更新来完成这个任务。请检查我的回答。 - Andrei Savu

15
"Django 1.11提供了三种方便的选项来处理这种情况,具体取决于您的业务逻辑需求:
  • Something.objects.select_for_update()会阻塞,直到模型变为空闲状态。
  • Something.objects.select_for_update(nowait=True)并捕获DatabaseError,如果模型当前被锁定以进行更新。
  • Something.objects.select_for_update(skip_locked=True)将不返回当前被锁定的对象。

在我的应用程序中,有交互和批量工作流程涉及各种模型,我发现这三个选项可以解决大部分并发处理场景。

“等待”select_for_update在顺序批处理过程中非常方便——我希望它们都执行,但让它们花费时间。当用户想要修改当前正在更新的对象时,使用nowait——我会告诉他们它正在此时被修改。

"

skip_locked 在另一种更新中非常有用,当用户可以触发对象的重新扫描时 - 我不关心谁触发了它,只要它被触发,因此 skip_locked 允许我静默跳过重复的触发器。


1
我需要在transaction.atomic()中包装select for update吗?如果我实际上要使用结果进行更新,那么它不会锁定整个表使得select_for_update无效吗? - Paul Kenjora

3

3
我觉得这是一个非常奇怪的想法。 - julx

1

无论如何,您应该至少使用django事务中间件,即使不考虑这个问题。

至于您实际遇到的多个用户编辑相同数据的问题...是的,请使用锁定。或者:

检查用户正在更新的版本(要安全地执行此操作,以防止用户简单地黑掉系统以表示他们正在更新最新副本!),并仅在该版本为当前版本时才进行更新。否则,向用户发送一个新页面,其中包含他们正在编辑的原始版本、提交的版本和其他人编写的新版本。请他们将更改合并为一个完全更新的版本。您可以尝试使用类似diff+patch的工具集自动合并这些内容,但是您需要让手动合并方法在失败情况下起作用,因此请从那里开始。此外,您需要保留版本历史记录,并允许管理员还原更改,以防某人无意或有意破坏合并。但是您可能应该一直这样做。

很可能有一个django应用程序/库为您完成大部分工作。


这也是乐观锁,就像Guillaume建议的那样。但他似乎得到了所有的分数 :) - hopla

0
另一个需要注意的是单词“atomic”。原子操作意味着您的数据库更改将要么成功发生,要么明显失败。快速搜索显示this question在问关于Django中的原子操作。

我不想在多个请求之间执行事务或锁定,因为这可能需要任意长的时间(甚至可能永远无法完成)。 - Ber
如果一个事务开始了,它必须结束。你应该在用户点击“提交”后才锁定记录(或启动事务,或者你决定要做什么),而不是在他们打开记录进行查看时就锁定。 - Harley Holcombe
是的,但我的问题不同,在这种情况下,两个用户打开相同的表单,然后他们都提交了更改。我认为锁定并不是解决这个问题的方法。 - Ber
你说得没错,但问题是这个情况下没有解决方案。一个用户会获胜,另一个会收到失败消息。你越晚锁定记录,就会遇到越少的问题。 - Harley Holcombe
我同意。我完全接受其他用户的失败消息。我正在寻找一种好的方法来检测这种情况(我预计这种情况非常罕见)。 - Ber

0

上面的想法

updated = Entry.objects.filter(Q(id=e.id) && Q(version=e.version))\
      .update(updated_field=new_value, version=e.version+1)
if not updated:
      raise ConcurrentModificationException()

看起来很不错,即使没有可序列化事务也应该能正常工作。

问题在于如何增强默认的 .save() 行为,以避免手动调用 .update() 方法进行管道操作。

我研究了自定义管理器的想法。

我的计划是覆盖 Manager _update 方法,该方法由 Model.save_base() 调用以执行更新操作。

这是 Django 1.3 中的当前代码。

def _update(self, values, **kwargs):
   return self.get_query_set()._update(values, **kwargs)

在我看来,需要做的是类似于:

def _update(self, values, **kwargs):
   #TODO Get version field value
   v = self.get_version_field_value(values[0])
   return self.get_query_set().filter(Q(version=v))._update(values, **kwargs)

删除操作也需要类似的事情发生。然而,由于Django通过django.db.models.deletion.Collector在这个领域实现了相当多的巫术,因此删除操作会更加困难。

很奇怪像Django这样的现代工具缺乏对乐观并发控制的指导。

当我解决这个谜题时,我会更新这篇文章。希望解决方案是以漂亮的Pythonic方式呈现的,不涉及大量编码、奇怪的视图、跳过Django的关键部分等。


-3

为确保安全,数据库需要支持 事务

如果字段是“自由格式”(例如文本等),并且需要允许多个用户能够编辑相同的字段(不能将数据归属于单个用户),则可以将原始数据存储在一个变量中。当用户提交时,检查输入数据是否已经与原始数据发生改变(如果没有,则不需要通过重写旧数据来打扰数据库),如果与数据库中的当前数据相比较,原始数据相同,则可以保存;如果原始数据发生了改变,则可以向用户显示差异并询问用户应该怎么做。

如果字段是数字,例如帐户余额、商店中的物品数量等,则可以更自动地处理。如果计算新值与原始值之间的差异,可以启动事务读取当前值并添加差异,然后结束事务。如果不能有负值,则如果结果为负数,应中止事务并告知用户。

我不知道 Django,所以无法提供代码... ;)


-6

从这里开始:
如何防止他人修改的对象被覆盖

我假设时间戳将作为表单中的隐藏字段保存,以保存详细信息。

def save(self):
    if(self.id):
        foo = Foo.objects.get(pk=self.id)
        if(foo.timestamp > self.timestamp):
            raise Exception, "trying to save outdated Foo" 
    super(Foo, self).save()

1
代码出现了问题。在 if 检查和保存查询之间仍可能发生竞态条件。您需要使用 objects.filter(id=.. & timestamp check).update(...) 并在未更新任何行的情况下引发异常。 - Andrei Savu

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接