【问题标题】:Concurrent requests in DjangoDjango 中的并发请求
【发布时间】:2013-09-28 06:54:13
【问题描述】:

我有 2 个模型:ProductOrder

Product 有一个用于股票的整数字段,而Order 有一个状态和一个指向Product 的外键:

class Product(models.Model):
    name = models.CharField(max_length=30)
    stock = models.PositiveSmallIntegerField(default=1)

class Order(models.Model):
    product = models.ForeignKey('Product')
    DRAFT = 'DR'; INPROGRESS = 'PR'; ABORTED = 'AB'
    STATUS = ((INPROGRESS, 'In progress'),(ABORTED, 'Aborted'),)
    status = models.CharField(max_length = 2, choices = STATUS, default = DRAFT)

我的目标是让产品的库存在每个新订单减少 1 次,并在每次订单取消时增加 1 次。 为此,我重载了 save 方法Order 模型本身(灵感来自 Django: When saving, how can you check if a field has changed?):

from django.db.models import F

class Order(models.Model):
    product = models.ForeignKey('Product')
    status = models.CharField(max_length = 2, choices = STATUS, default = DRAFT)

    EXISTING_STATUS = set([INPROGRESS])

    __original_status = None

    def __init__(self, *args, **kwargs):
        super(Order, self).__init__(*args, **kwargs)
        self.__original_status = self.status

    def save(self, *args, **kwargs):
        old_status = self.__original_status
        new_status = self.status
        has_changed_status = old_status != new_status
        if has_changed_status:
            product = self.product
            if not old_status in Order.EXISTING_STATUS and new_status in Order.EXISTING_STATUS:
                product.stock = F('stock') - 1
                product.save(update_fields=['stock'])
            elif old_status in Order.EXISTING_STATUS and not new_status in Order.EXISTING_STATUS:
                product.stock = F('stock') + 1
                product.save(update_fields=['stock'])
        super(Order, self).save(*args, **kwargs)
        self.__original_status = self.status

使用 RestFramework,我创建了 2 个视图,一个用于创建新订单,一个用于取消现有订单。两者都使用简单的序列化程序:

class OrderSimpleSerializer(serializers.ModelSerializer):

    class Meta:
        model = Order
        fields = (
            'id',
            'product',
            'status',
        )
        read_only_fields = (
            'status',
        )

class OrderList(generics.ListCreateAPIView):
    model = Order
    serializer_class = OrderSimpleSerializer

    def pre_save(self, obj):
        super(OrderList,self).pre_save(obj)
        product = obj.product
        if not product.stock > 0:
            raise ConflictWithAnotherRequest("Product is not available anymore.")
        obj.status = Order.INPROGRESS

class OrderAbort(generics.RetrieveUpdateAPIView):
    model = Order
    serializer_class = OrderSimpleSerializer

    def pre_save(self, obj):
        obj.status = Order.ABORTED

以下是访问这两个视图的方法:

from myapp.views import *

urlpatterns = patterns('',
    url(r'^order/$', OrderList.as_view(), name='order-list'),
    url(r'^order/(?P<pk>[0-9]+)/abort/$', OrderAbort.as_view(), name='order-abort'),
)

我正在使用 Django 1.6b4、Python 3.3、Rest Framework 2.7.3 和 PostgreSQL 9.2。

我的问题是并发请求可以增加产品的库存高于原始库存!

这是我用来演示的脚本:

import sys
import urllib.request
import urllib.parse
import json

opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor)

def create_order():
    url = 'http://127.0.0.1:8000/order/'
    values = {'product':1}
    data  = urllib.parse.urlencode(values).encode('utf-8')
    request = urllib.request.Request(url, data)
    response = opener.open(request)
    return response

def cancel_order(order_id):
    abort_url = 'http://127.0.0.1:8000/order/{}/abort/'.format(order_id)
    values = {'product':1,'_method':'PUT'}
    data  = urllib.parse.urlencode(values).encode('utf-8')
    request = urllib.request.Request(abort_url, data)
    try:
        response = opener.open(request)
    except Exception as e:
        if (e.code != 403):
            print(e)
    else:
        print(response.getcode())

def main():
    response = create_order()
    print(response.getcode())
    data = response.read().decode('utf-8')
    order_id = json.loads(data)['id']
    time.sleep(1)
    for i in range(2):
        p = Process(target=cancel_order, args=[order_id])
        p.start()

if __name__ == '__main__':
    main()

对于库存为 1 的产品,此脚本提供以下输出:

201 # means it creates an order for Product, thus decreasing stock from 1 to 0
200 # means it cancels the order for Product, thus increasing stock from 0 to 1
200 # means it cancels the order for Product, thus increasing stock from 1 to 2 (shouldn't happen)

编辑

我添加了一个示例项目来重现该错误: https://github.com/ThinkerR/django-concurrency-demo

【问题讨论】:

    标签: django postgresql transactions django-rest-framework


    【解决方案1】:

    看看django-concurrency。它使用optimistic concurrency control pattern 处理并发编辑。

    【讨论】:

    • 我认为它非常适合处理并发请求的一般问题,但对于我的单一视图/模型来说它是多余的。
    【解决方案2】:

    我认为问题不在于以原子方式更新产品计数——Django ORM 的F() 表达式应该正确处理该问题。但是结合操作:

    1. 检查订单状态(产品数量需要更新吗?)
    2. 更新产品数量
    3. 更新订单状态(取消)

    不是原子操作。对于两个线程 A 和 B(都处理同一订单的取消请求),可能会有以下事件序列:

    A:查看订单状态:新订单已取消,与之前订单不同
    B:查看订单状态:新的已取消,与之前的不同
    A:自动更新产品数量从 0 到 1
    B:自动更新产品数量从 1 到 2
    A:将订单状态更新为已取消
    B:更新订单状态为取消

    您需要执行以下操作之一:

    1. 将整个操作更改为在事务中发生。您没有提到您的数据库或事务模式设置。 Django 默认为自动提交模式,其中每个数据库操作都是单独提交的。要更改为事务(可能与 HTTP 请求相关联),请参阅 https://docs.djangoproject.com/en/dev/topics/db/transactions/
    2. 创建同步屏障以防止两个线程更新产品计数。您可以通过数据库层上的原子比较和交换操作来执行此操作,但我不确定是否有现成的 F 或类似的原语来执行此操作。 (主要思想是更改订单和产品数据更新的顺序,以便您首先原子地更新和测试订单状态。)
    3. 使用其他形式的同步机制,使用分布式锁系统(您可以为此使用 Redis 和许多其他系统)。不过,我认为这对于这种情况来说有点矫枉过正。

    总结:除非您已经为您的应用程序使用 HTTP 级别的事务,否则请尝试在您的 Django 配置文件 (settings.py) 中设置 ATOMIC_REQUESTS = True

    如果您不这样做或不能这样做,请注意,替代方法不会为您提供订单-产品对的一致性。试着想想如果 Django 服务器在产品更新和订单更新之间崩溃会发生什么——只有一个会被更新。 (这是必须的数据库事务,数据库引擎注意到客户端由于网络连接中断而中止并回滚事务。)

    【讨论】:

    • 我试过ATOMIC_REQUESTS = True,但没用。我不明白为什么。这就是为什么我来这里哭着求救。正如@esauro 答案的评论中所说,select_for_update(nowait=True) 满足我的需要。但是我不确定在服务器崩溃的情况下会发生什么行为。
    • 您使用的数据库后端是什么? MySQL、Postgres,还有其他什么...?
    • 检查 OP:PostgreSQL 9.2
    • 我认为我的问题来自事务和锁之间的混淆。目前这对我来说仍然有点模糊......我通过在 Order 上使用 select_for_update 进行上游事务解决了我的问题,该事务具有锁定数据库中的行的属性。
    【解决方案3】:

    正如您提到的,您对并发请求有竞争条件。要摆脱这种情况,您应该使操作原子化。我要做的是使用 Redis 使订单操作原子化。然后尽可能写回常规数据库。

    http://redis.io/

    编辑:

    经过一些cmets,似乎最好的方法是包含select_for_update(wait=True)

    【讨论】:

    • 只有使用 Postgre 才能让它工作吗?目前,redis 解决方案对我来说似乎有点矫枉过正。
    • 您可以通过多种方式做到这一点。你甚至可以实现一个信号量(我目前在一个项目中这样做)。通过锁使用 postgres 可能是另一种选择,但我认为 django 本身并不支持它。看看stackoverflow.com/questions/320096/…
    • 嘿,谢谢你的链接。这是有效的:select_for_update(nowait=True)。我自己试过nowait=False,但没有用,但我刚刚测试了nowait=True,它可以工作。将继续测试并发布答案。
    猜你喜欢
    • 2023-01-20
    • 2012-08-04
    • 2023-03-03
    • 1970-01-01
    • 2022-01-23
    • 2020-10-18
    • 1970-01-01
    • 2012-09-26
    • 2020-10-30
    相关资源
    最近更新 更多