← 返回蜂巢洞察

如何防止在 Django 中出现竞态条件现象

假设你还有足够的信用点数,可以在一个AI应用中再生成一张图片。你在一个浏览器标签页中提交请求,然后在第一个请求完成之前,在另一个标签页中再次提交请求。 该应用会接受这两份请求。 在后台,每份请求都会经过信用点数检查。但问题在于,该应用接收的任务量超出了你的剩余信用点数所能支撑的范围。你刚才所经历的情况就被称为“竞争条件”。 对于开发者来说,这会引发两个问题: 如果只有足够的信用点数完成一个请求,那么这两份请求是如何都通过检查的呢? 如何防止它们消耗相同的信用点数呢? 在本指南中,我们将构建一个简单的Django信用系统来探讨这些问题。我们会重现这一故障现象,然后利用数据库事务和行锁机制来解决它

假设你还有足够的信用点数,可以在一个AI应用中再生成一张图片。你在一个浏览器标签页中提交请求,然后在第一个请求完成之前,在另一个标签页中再次提交请求。

该应用会接受这两份请求。

在后台,每份请求都会经过信用点数检查。但问题在于,该应用接收的任务量超出了你的剩余信用点数所能支撑的范围。你刚才所经历的情况就被称为“竞争条件”。

对于开发者来说,这会引发两个问题:

  • 如果只有足够的信用点数完成一个请求,那么这两份请求是如何都通过检查的呢?

  • 如何防止它们消耗相同的信用点数呢?

在本指南中,我们将构建一个简单的Django信用系统来探讨这些问题。我们会重现这一故障现象,然后利用数据库事务和行锁机制来解决它,并测试当两个请求同时争夺剩余的信用点数时会发生什么。

我们将会涵盖的内容

本指南适合哪些人阅读

本指南适用于那些已经了解Django基础知识,但还不熟悉并发编程的开发者。

如果你对模型、迁移文件和视图函数有一定了解,那么阅读这些示例会更加容易。进行以下设置时,你需要使用Python 3.12以及带有Compose功能的Docker。我们会使用Django 5.2、Django REST Framework 3.16和PostgreSQL 17版本,因为SQLite不支持我们在这里要使用的行锁机制。

在将并发概念应用到代码之前,我会先对其进行详细的解释。

在整个教程中,图片生成过程都将是模拟进行的。

我们的API会接收用户输入的指令,并返回模拟生成的结果。你不需要使用AI服务提供商的账户或支付API密钥,这样就可以专注于研究信用点数分配机制及其对数据库的影响了。

你可以完全按照示例中的步骤进行操作,而无需实际支付任何费用来生成图片。

并发与竞争条件的含义

什么是并发?

并发指的是两个或多个任务在时间上有所重叠的情况下同时进行执行。

例如,服务器可以在请求A等待数据库响应的同时开始处理请求B。这些任务的执行顺序并不需要完全同步,Python通过事件循环机制可以讓它们在不同的时刻被执行,这就是Python支持并发编程的方式之一。

关键在于:在第一个任务完成之前,另一个任务就可以已经开始执行了。

什么是竞态条件?

竞态条件是一种缺陷,其导致的错误结果取决于并发操作的执行时机或顺序。

当多个操作共享数据,而应用程序没有妥善协调这些操作的访问方式时,就可能出现竞态条件。每个操作单独来看可能是正确的,但其中一个操作可能会基于另一个操作已经修改过的数据来执行后续操作,从而导致最终结果的错误。不同的执行顺序就可能会产生不同的、错误的结果。

并发性为操作之间的重叠提供了可能性,而竞态条件正是应用程序在处理这种重叠时可能出现的错误类型。

竞态条件还可能出现在哪些地方?

竞态条件可能会影响计数器、用户账户、后台任务,以及浏览器中显示的结果。

参与这些竞态条件的操作可以是由同一人发出的两个请求,也可以是两个不同的用户发起的操作,甚至还可以是完全自动执行的任务。被共享的资源可以是数据库中的记录、文件、内存中的数据,或者是页面的当前状态。以下示例说明了顺序在不同情况下会产生的重要影响。

需要注意的是那些会共享状态且可能互相干扰的操作,在这些操作完成之前,务必注意它们的执行顺序。

视图计数器丢失了更新结果

假设有两个请求试图将某篇文章的浏览次数从100增加。

这两个请求在保存更改之前都读取到了当前的浏览次数,即100。然后它们各自将浏览次数增加1,最终都将数值写入数据库,变为101。但实际上计数器应该变成102,因此其中一次更新结果被丢失了。

这就是所谓的“丢失的更新”,这里并没有涉及任何购买操作或库存限制的问题。

两次注册尝试使用相同的用户名

如果注册表单仅依赖初步的用户名检查机制,就可能会发生竞态条件。

有两个请求检查了同一个用户名,结果都发现该用户名可用,于是它们都试图用这个用户名创建账户。如果没有数据库中的唯一性校验规则,应用程序就很有可能生成重复的用户名。

为避免这种情况,应该在数据库中强制实施用户名的唯一性约束,而不能仅仅依赖最初的检查。

两个后台任务选择了相同的作业来处理

后台任务可能会无意中同时处理同一个待处理的作业。

这两个任务在开始执行之前都会先读取该作业的状态,然后都认为这个作业是可用的,于是就开始执行。这样,应用程序就可能会发送两次通知,或者生成两份相同的报告。

因此,必须有一种机制来协调这些任务的执行顺序,确保不会发生重复操作或错误结果。

信用检查为何会失败

首先,让我们回顾一下本教程开头所提到的规则:每个图像请求都会消耗一个信用点。这些信用点代表了应用程序的使用权,它们与模型处理的令牌是不同的。

这是我们在本教程中选择采用的规则。如果一个账户最初拥有十个信用点,那么在处理了九个请求后,仍然会剩下一个信用点可供使用。

为了接受一个请求,后端必须执行以下操作:

  1. 读取账户的剩余信用点数量。

  2. 检查是否还至少剩有一个信用点。

  3. 扣除一个信用点,并更新账户余额。

  4. 记录下被接受的请求信息。

当一次只测试一个请求时,这个流程看起来是正确的。第一个请求执行完毕后,账户余额会变为零;第二个请求也会检测到余额为零,从而正常完成处理。

接下来,我们需要观察在多个请求同时被执行时的情况。

当两个请求同时被执行时会发生什么

假设请求A读取账户信息后发现还剩一个信用点,在它更新数据库之前,请求B也读取了同一账户的信息,同样发现了剩余的一个信用点。

此时,每个请求都会拥有自己独立的余额副本。由于两个请求都检测到了剩余的信用点,因此它们都会成功通过检查,并且计算结果都是1 - 1 = 0。请求A会将余额设为零并记录一次操作;请求B也会将余额设为零并再次记录一次操作。

最终账户的余额确实是零,但应用程序实际上接收了两个请求。如果只是简单地检查余额是否为负数,那么这种错误就无法被发现。

这个错误的根源在于:在某个请求有机会改变账户余额之后,就直接信任了该余额的值。为了直观地了解这一现象,我们首先会构建一个包含这种错误的示例程序。

如何搭建Django项目

首先创建一个项目目录,并设置一个虚拟环境。以下命令适用于Linux和macOS系统:

mkdir django-credit-demo
cd django-credit-demo
python3.12 -m venv .venv
source .venv/bin/activate

这些命令会为我们的示例项目创建一个独立的目录和Python环境。

mkdir用于创建目录,cd用于进入该目录,venv用于创建一个名为.venv的隔离环境,source命令用于激活这个环境,这样后续安装的包就都属于这个项目了。

在执行以下命令时,请确保这个虚拟环境处于激活状态。

在Windows系统中,可以使用py -3.12 -m venv .venv来创建环境,然后使用.venv\Scripts\Activate.ps1在PowerShell中激活它。

接下来创建requirements.txt文件:

Django>=5.2,<5.3
djangorestframework>=3.16,<3.17
psycopg[binary]>=3.2,<3.3

该文件列出了该项目所需的三个软件包。

Django提供了模型和数据库工具,而Django REST Framework则负责处理接口端点。Psycopg用于连接PostgreSQL数据库,而[binary]则用于使用其预构建的实现版本。每个版本范围都允许在所选发行系列内进行更新,但不会包括下一个发行系列。

使用一个需求文件可以让所有按照指南操作的人清楚地了解项目所需的依赖关系。

请安装这些软件包,创建项目,并添加一个名为credits的应用程序:

python -m pip install -r requirements.txt
python -m django startproject config .
python manage.py startapp credits

这些命令会安装所需的依赖关系并构建应用程序的结构。

pip install -r会从requirements.txt中读取软件包列表。startproject命令会创建config包以及manage.py文件,其中最后的点表示当前目录。startapp命令会创建credits包,我们的模型、服务函数、视图和测试文件都将存放在这个包中。

现在,我们已经准备好了一个可以连接到数据库的Django项目。

启动PostgreSQL服务器

在manage.py旁边创建compose.yaml文件:

services:
  db:
    image: postgres:17
    environment:
      POSTGRES_DB: credit_demo
      POSTGRES_USER: credit_demo
      POSTGRES_PASSWORD: local-demo-only
    ports:
      - "127.0.0.1:5433:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U credit_demo -d credit_demo"]
      interval: 2s
      timeout: 5s
      retries: 15

此配置文件描述了一个用于本地开发的PostgreSQL容器。

image指定了使用PostgreSQL 17版本,环境变量则设置了数据库用户名和密码。端口映射使得只有在本机的回环地址上才能通过5433端口访问该数据库。健康检查功能会每两秒运行一次pg_isready命令,每次检查允许最多5秒钟的时间,如果连续15次检查都失败,系统才会认为该容器处于不健康状态。

Django会在其配置文件中使用这些相同的连接信息。

请使用以下命令启动这个容器:

docker compose up -d --wait

此命令会启动compose.yaml中定义的PostgreSQL数据库服务。

up命令用于创建并启动服务。-d选项表示让服务在后台运行,这样你就可以继续使用终端进行其他操作。--wait选项则会让系统等待直到服务通过健康检查。

配置Django项目

请将config/settings.py文件替换为以下最基本的配置内容:

import os

SECRET_KEY = "local-tutorial-only-do-not-use-in-production"
DEBUG = True
ALLOWED_HOSTS = ["localhost", "127.0.0.1", "testserver"]
INSTALLED_APPS = [
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "rest_framework",
    "credits",
]
MIDDLEWARE = []
ROOT_URLCONF = "config.urls"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
USE_TZ = True
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": os.environ.get("DB_NAME", "credit_demo"),
        "USER": os.environ.get("DB_USER", "credit_demo"),
        "PASSWORD": os.environ.get("DB_PASSWORD", "local-demo-only"),
        "HOST": os.environ.get("DB_HOST", "127.0.0.1"),
        "PORT": os.environ.get("DB_PORT", "5433"),
        "OPTIONS": {"options": "-c lock_timeout=5000 -c statement_timeout=10000"},
    }
}
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.BasicAuthentication",
    ],
    "DEFAULT_PERMISSION_classes": [
        "rest_framework.permissions.IsAuthenticated",
    ],
}

这个配置文件用于连接我们这个小型应用程序的各个部分。

INSTALLED_APPS选项启用了Django的用户认证功能、Django REST Framework,以及我们的credits应用。ROOT_URLCONF指定了路由定义的位置,而DEFAULT_AUTO_FIELD和USE_TZ则负责配置自动标识符以及支持时区的日期格式。空白的MIDDLEWARE列表使得这个API示例保持简洁性,而ALLOWED_HOSTS允许本地地址和测试客户端进行访问。

这些配置是专门为本教程中的端点设计的。

DATABASES选项告诉Django如何连接PostgreSQL数据库。

ENGINE指定了使用PostgreSQL作为后端数据库。每个os.environ.get()语句都会尝试读取相应的环境变量,如果找不到该变量,则会使用默认值。这些配置设置了5秒钟的锁定超时时间和10秒钟的语句执行超时时间,这样在操作遇到阻塞时,系统会立即报错而不会无限期地等待。

对于超时情况,这个简单的端点并没有提供自定义的处理方式。

REST_FRAMEWORK选项要求用户必须经过认证才能使用相关功能。

BasicAuthentication会读取请求中携带的认证信息;IsAuthenticated会在视图函数接受请求之前拒绝匿名访问者。示例中使用的密钥以及DEBUG = True配置属于本地开发环境下的设置。

在正式部署时,需要配置生产环境的密钥、合适的认证机制以及加密连接方式。

目前可以先将config/urls.py文件中的路由列表留空。等我们完成信用信息处理逻辑的编写后,再添加相应的端点:

urlpatterns = []

这个空的路由列表暂时使得Django无法识别任何应用路由。

Django会从ROOT_URLCONF中指定的模块中读取urlpatterns配置。由于当前配置不包含管理员相关功能,因此系统中没有自动生成的管理员路由;不过在端点创建之前,仍然可以执行数据库操作。

等受保护的视图功能准备好之后,我们会更新这个路由列表。

创建模型

请将这些模型添加到credits/models.py文件中:

from django.conf import settings
from django.db import models


class CreditAccount(models.Model):
    user = models.OneToOneField(settings.auth_user_MODEL, on_delete=models.CASCADE)
    balance = models.PositiveIntegerField(default=0)


class Generation(models.Model):
    account = models.ForeignKey(CreditAccount, on_delete=models CASCADE)
    prompt = models.CharField(max_length=500)
    status = models.CharField(max_length=20, default="reserved")
    created_at = models.DateTimeField(auto_now_add=True)

CreditAccount模型用于存储与用户关联的余额信息。

settings.auth_user_MODEL指定了项目中使用的用户模型。OneToOneField确保每个用户最多只能拥有一个账户;on_delete=models.CASCADE则会在删除用户时同时删除其账户信息。PositiveIntegerField(default=0)用于存储非负整数类型的余额,初始值为0。

balance字段代表了我们的信用额度,但仅凭这个字段类型是无法确保每笔交易都只对应一次生成操作的。 Generation字段用于存储应用程序已接收到的处理请求信息。 外键将每条记录与其对应的支付账户关联起来,因此一个账户可以存在多条生成记录。prompt字段最多可容纳500个字符,而created_at字段则记录了该行数据创建的时间。status字段的初始值为reserved,这意味着我们的系统已经记录下了该请求,但尚未完成模拟图像生成工作。 保留这些生成记录有助于我们了解被扣用的信用额度实际上是用于支付什么内容的。 创建并应用迁移脚本:
python manage.py makemigrations credits
python manage.py migrate
这些命令会将模型定义转换为数据库表结构。 makemigrations credits命令会生成描述模型变更内容的迁移文件,而migrate命令则会应用这些待执行的迁移操作,包括Django自带的用户表以及我们新创建的这两个表。这些操作所使用的连接信息来自于我们刚刚配置的数据库设置。 现在,数据库已经准备好存储账户信息及其相关的生成请求记录了。

如何重现“竞争条件”现象

创建文件credits/services.py,并在其中添加以下这个存在安全风险的函数:
from .models import CreditAccount, Generation


class InsufficientCredits(Exception):
    pass


def reserve_generation_unsafe(user_id, prompt):
    account = CreditAccount.objects.get(user_id=user_id)

    if account.balance < 1:
        raise InsufficientCredits

    account.balance -= 1
    account.save(update_fields=["balance"])

    return Generation.objects.create(account=account, prompt=prompt)
这个函数在没有任何并发控制机制的情况下执行了信用额度检查操作。 objects.get()方法会根据user_id检索对应的账户信息,而如果账户余额低于1,则会抛出InsufficientCredits异常。接着,该函数会减少账户的余额,然后通过save(update_fields=["balance"])将新的余额值写入数据库。最后,Generation.objects.create()方法会插入新生成的请求记录,并返回相应的模型对象。 由于读操作和写操作是独立进行的,因此在其他请求正在执行的过程中,这两个操作仍然可以同时发生。 InsufficientCredits这个异常类为调用者提供了明确的错误提示信息,便于他们进行处理。 这个异常类是从Python的Exception基类继承而来的。由于其中使用了pass语句,因此这个类本身没有添加任何额外的功能。后续,在视图函数中捕获到这个异常后,可以返回相应的错误响应给用户。 为了便于对比分析,可以保留这个存在安全风险的函数,但不要通过它来处理实际的业务请求。

确保读操作总是在写操作之前完成

打开两个浏览器标签页并不能可靠地重现这一故障。因为其中一个请求可能在另一个请求读取账户余额之前就已经完成处理了。因此,我们将使用两个Django shell工具,在每个工具读取完账户信息后暂停程序的执行。

在您的项目目录中打开一个终端,激活虚拟环境,然后启动shell: ```bash python manage.py shell ``` 这条命令会打开一个Python shell,并加载Django项目。该shell会使用与`manage.py`关联的配置信息,因此您可以直接导入模型并查询数据库。每个终端都会为我们的实验提供一个独立的shell环境。我们会利用这些shell来控制数据库操作的顺序。 接下来,创建一个新的用户账户,并为其分配1个信用点数: ```python from django.contrib.auth import get_user_model from credits.models import CreditAccount, Generation user = get_user_model().objects.create_user(username="race-demo") CreditAccount.objects.create(user=user, balance=1) account_a = CreditAccount.objects.get(user__username="race-demo") print(account_a.balance) ``` 这段代码创建了一个新用户,并为其分配了1个信用点数。`get_user_model()`用于获取用户模型,`create_user()`用于创建用户账户,同时为该账户分配1个信用点数。通过`user__username`字段可以关联到相应的用户对象,然后将生成的`CreditAccount`对象存储在`account_a`中。执行这条命令后,控制台应该会显示“1”。 现在,在另一个终端中激活相同的虚拟环境,再次运行`python manage.py shell`,然后查看账户余额: ```python from credits.models import CreditAccount, Generation account_b = CreditAccount.objects.get(user__username="race-demo") print(account_b.balance) ``` 这个shell会从数据库中读取相同的信息,但会生成一个不同的`CreditAccount`对象。由于第一个shell还没有进行任何操作,因此`account_b`的余额也应该仍然是1。在另一个shell中进行的修改不会自动影响到这个对象。 现在,两个shell都拥有相同的信用点数副本。接下来,在第一个shell中执行以下代码: ```python if account_a.balance >= 1: account_a.balance -= 1 account_a.save(update_fields=["balance"]) Generation.objects.create(account=account_a, prompt="A garden") ``` 这段代码会检查`account_a`的余额是否大于或等于1,如果是,则从余额中减去1,并将更新后的信息保存到数据库中。同时,还会为“花园”提示创建一个新的记录。 在完成这些操作后,请切换回另一个终端,在那里执行相应的代码: ```python if account_b.balance >= 1: account_b.balance -= 1 account_b.save(update_fields=["balance"]) Generation.objects.create(account=account_b, prompt="A beach") ``` 这样,两个shell就会分别完成各自的操作。

第二个shell会使用它之前加载的对象来做出决策。

由于其if条件仍然成立,因为我们还没有更新account_b的值。它会减去1,并将余额设置为0,从而覆盖掉第一次操作所记录的数值。随后,它会为海滩提示信息生成一个新的记录。

第二次操作实际上是使用了第一次操作已经消耗掉的信用额度来执行相应的操作。

最后,让我们通过第二个shell来检查数据库中的数据:

account_b.refresh_from_db()
print(account_b.balance)
print(Generation.objects.filter(account=account_b).count())

这段代码用于检查两次操作所产生的结果。

refresh_from_db()这个方法会重新加载账户信息,因此打印出来的数值会反映数据库中的实际余额。filter()方法之后执行的count()只会计算与该账户相关联的记录数量。你应该会看到信用额度为0,同时存在两条记录。

通过这些数据,我们可以发现那些仅凭余额信息是无法发现的错误。

这两个shell让我们能够故意重现这种不安全的操作顺序。

我们在每次读取数据后都会暂停程序的执行,然后再执行两次写操作。即使不是每次尝试都会出现这种情况,但当多个请求同时发生时,服务器也有可能产生相同的操作顺序。为了重复这个实验,请创建一个新的用户名和账户,以确保之前的记录不会影响最终的统计结果。

现在我们可以针对我们所观察到的这种安全漏洞来设计相应的防护措施了。

如何保护账户余额

在这个过程中,存在两个与数据库相关的问题:信用额度的扣除操作和记录的生成操作必须同时成功完成;此外,多个并发请求也需要以协调一致的方式来检查和更新账户信息。

我们将按照这些顺序来解决这些问题。

数据库事务能够将多个操作组合成一个完整的工作单元。Django提供的transaction.atomic()方法会在整个操作流程成功完成时提交这些变更;如果过程中出现异常,这些变更就会被回滚。

这一点非常重要,因为不安全的代码会在生成记录之前先更新账户余额。如果记录生成失败,那么扣除操作所造成的余额变化就可能无法得到相应的确认。

为了说明这一机制,我们可以将相关操作包装在一个事务中执行,具体代码如下:

from django.db import transaction


@transaction.atomic
def reserve_generation_atomic_only(user_id, prompt):
    account = CreditAccount.objects.get(user_id=user_id)

    if account.balance < 1:
        raise InsufficientCredits

    account.balance -= 1
    account.save(update_fields=["balance"])

    return Generation.objects.create(account=account, prompt=prompt)

这个装饰器将函数中的所有数据库操作都放在了一个原子事务中执行。

from django.db import transaction这一行代码引入了Django提供的事务处理功能。该函数仍然会按照原来的顺序来读取账户信息、检查余额、扣除金额以及生成新的记录。如果在生成记录的过程中出现异常,整个事务就会被回滚,从而避免数据错误的发生。

这种机制能够保护扣款操作与相应的记录生成过程之间的关系。

然而,普通的账户查询方式仍然会暴露信用检查的相关信息。

PostgreSQL将“读已提交”作为默认的隔离级别。在这种模式下,一个事务中的普通读取操作不会导致另一个事务在读取数据之前必须等待。因此,两个事务都可以先读取某项信用记录的信息,然后再对其进行更新。

在确定账户余额之前,我们必须协调各种访问操作。

在检查余额之前锁定账户

行锁是一种数据库机制,它能够在事务持有锁的情况下,阻止对相关数据行的并发修改操作。

在这个例子中,某一行记录代表了一个信用账户的信息。使用`SELECT FOR UPDATE`命令可以锁定这条记录,这样其他试图更新该记录的操作就必须等待直到锁被释放。普通的读取操作仍然可以进行,其他事务也可以继续处理其他账户的数据。

这种机制能够确保在检查或修改账户余额的过程中,相关数据的安全性得到保障。

请在`credits/services.py`文件的顶部添加以下代码:`from django.db import transaction`。保留原有的不安全版本作为对比,然后再添加这个经过保护的版本:

@transaction.atomic
def reserve_generation(user_id, prompt):
    account = CreditAccount.objects.select_for_update().get(user_id=user_id)

    if account.balance < 1:
        raise InsufficientCredits

    account.balance -= 1
    account.save(update_fields=["balance"])

    return Generation.objects.create(account=account, prompt=prompt)

这个函数在检查账户余额之前,会先获取该记录的行锁。

`select_for_update()`命令用于申请锁,而`.get(user_id=user_id)`则会在事务范围内针对指定账户执行查询操作。一旦获得了锁,函数就会检查账户余额;如果余额不足,就会抛出`InsufficientCredits`异常。否则,它就会保存扣款信息并创建新的记录,直到事务完成。

现在,所有的决策以及相关的数据库操作都是在账户被锁定之后进行的。

如果有其他程序同时尝试调用这个函数,它们就必须等待锁被释放。

如果第一个请求成功完成了扣款操作,在“读已提交”隔离级别的环境下,后续的请求会看到已经更新后的余额。而如果第一个请求被撤销,那么之前的扣款操作也会被取消。我们配置的超时机制也可以在等待时间过长时终止请求进程。

对于一个余额为1的账户来说,如果第一个请求成功完成了扣款操作,那么第二个请求就会发现余额为0,从而拒绝该请求。

这种保护措施必须覆盖所有可能消耗账户余额的操作路径。

一些旧版本的函数可能仍然可以在不申请锁的情况下读取账户信息。虽然后续的更新操作会等待锁被释放,但它们仍然可以使用过时的余额值来覆盖原有数据。因此,即使是管理性操作或后台任务,也需要采用安全的更新策略。

仅仅依靠一个经过保护的函数是无法确保整个系统的数据安全性的;系统中其他不安全的操作依然可能破坏数据的完整性。

将图像生成操作置于事务之外

事务应当涵盖信用额度的预留过程。远程图像请求所需的时间通常会比数据库操作长得多,因此如果将这种请求包含在事务中,就会导致其他竞争性请求无谓地等待。

在我们的演示中,请将以下代码添加到credits/services.py文件中:

def simulate_generation(generation):
    # 本教程中不会发起任何外部AI请求。
    generation.status = "completed"
    generation.save(update_fields=["status"])
    return "模拟的图像生成已完成。"

这个函数用于模拟图像生成的完成过程。

它会将传入的generation对象的status字段设置为completed。调用save()方法只会保存这一字段的值。该函数的返回值仅是一条消息,因此它并不会实际生成图像,也不会发起任何外部请求。

我们会在预留信用额度的函数执行完毕之后再调用这个函数。

这样的调用顺序能够确保图像生成操作不会被包含在预留信用额度的事务中。

在这种设置下,如果没有任何外部事务包围这些操作,那么预留信用额度的操作就会在模拟生成图像的操作开始之前完成。因此,如果在后续过程中出现故障,系统也不会自动恢复用户的信用额度。对于真实的图像服务提供商来说,他们需要自行制定重试或退款政策。

在测试了预留信用额度的功能之后,我们还会进一步讨论这些相关问题。

如何接收并测试图像请求

现在我们可以将这个受保护的函数连接到相应的接口端点了。请将以下代码添加到credits/views.py文件中:

from rest_framework import serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import CreditAccount
from .services import InsufficientCredits, reserve_generation, simulategeneration


class GenerationInput(serializers Serializer):
    prompt = serializers.CharField(max_length=500)


class GenerateView(APIView):
    def post(self, request):
        serializer = GenerationInput(data=request.data)
        serializer.is_valid(raise_exception=True)
        try:
            generation = reserve_generation(
                request.user.pk, serializer.validated_data["prompt"]
            )
        except CreditAccount.DoesNotExist:
            return Response({"detail": "未找到相应的信用账户。", status=404}
        except InsufficientCredits:
            return Response({"detail": "剩余的信用额度不足。", status=409})

        result = simulate_generation(generation)
        return Response(
            {"id": generation.pk, "status": generation.status, "result": result},
            status=status.HTTP_201_created,
        )

在视图函数实际消耗信用额度之前,GenerationInput会先检查用户输入的提示信息。

GenerationInput定义了一个必填的文本字段,其最大长度为500个字符。调用is_valid(raise_exception=True)方法可以确保缺失、为空或无效的输入会被拒绝,并返回相应的错误信息。之后,视图函数会从validated_data中获取经过验证后的输入内容,并将其与经过身份验证的用户标识符request.user.pk一起传递给预留信用额度的函数。

客户端提供提示信息,而服务器则从已认证的用户中选择相应的账户进行处理。

视图会将预订的结果转换成响应信息并返回给客户端。

如果找不到对应的账户,会返回404错误码;而当账户余额不足时,则会返回409错误码。对于余额冲突的情况,我们选择了返回409作为响应方式。在预订操作成功后,视图会在收到结果后调用相应的模拟功能,并返回201状态码,同时附带记录标识符、完成状态以及模拟结果。

通过这些不同的响应代码,调用方可以区分请求是被接受还是被拒绝。

请将config/urls.py文件替换为以下内容:

from django.urls import path
from credits.views import GenerateView

urlpatterns = [
    path("api/generate/", GenerateView.as_view()),
]

这条路由将请求地址与我们定义的视图函数关联起来。

path()用于匹配地址中的api/generate/部分;GenerateView.as_view()则将该基于类的视图函数转换为Django可以调用的函数形式。该视图的post()方法会处理发送到该路由的POST请求。

当服务器启动后,可以通过/api/generate/这个地址访问相应的接口功能。

一次只尝试一个请求

打开Django shell,为演示 purposes创建一个单独的用户账户:

from django.contrib.auth import get_user_model
from credits.models import CreditAccount

user = get_user_model().objects.create_user(
    username="api-demo",
    password="local-example-password",
)
CreditAccount.objects.create(user=user, balance=1)

这段代码用于为演示示例创建一个用户账户。

create_user()方法会保存用户名并对提供的密码进行加密处理;CreditAccount.objects.create()则会为新用户分配1个信用点数。使用单独的用户名可以确保这个测试账户与在另一终端中使用的账户相互独立。

下面这些凭据仅适用于这次本地演示实验。

退出shell环境,然后启动开发服务器:

python manage.py runserver

这条命令会启动Django的开发服务器。

如果没有指定地址参数,服务器会监听127.0.0.1:8000这个端口。发送到该地址的请求都会经过我们刚刚配置的路由规则。在从其他终端发送请求时,请保持当前终端处于打开状态。

这个服务器仅用于本地演示目的。

在另一个终端中,发送如下请求:

curl -i -u api-demo:local-example-password \
  -H "Content-Type: application/json" \
  -d '{"prompt": "A garden at sunrise"}' \
  http://127.0.0.1:8000/api/generate/

这条命令会使用演示用户账户的凭据,向该接口发送提示信息。

-i选项用于显示响应头信息;-u选项用于指定基本认证所需的用户名和密码;-H选项指明请求体的格式为JavaScript Object Notation(即JSON);-d选项用于提供请求体内容,从而让curl向指定的地址发送POST请求。
第一个请求应该返回代码201,表示模拟操作已经完成。 再次发送该命令后,系统会检查账户在第一次扣款之后的状态。 下一个请求应该会发现账户中的信用点数为零。此时视图应返回代码409,并附带信息"detail": "信用点数不足。"。由于我们在两次请求之间等待了一段时间,因此这一测试能够验证正常的操作顺序,而不会出现并发执行的情况。 接下来,我们将测试并发操作带来的影响。

设置自动化测试环境

需要将文件credits/tests.py替换为以下内容,其中包含导入语句和辅助类。具体的测试方法我们将在后续步骤中添加。
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
from unittest.mock import patch

from django.contrib.auth import get_user_model
from django.db import (
    OperationalError, close_old_connections, connection, connections, transaction,
)
from django.test import TransactionTestCase
from rest_framework.test import APIClient

from .models import CreditAccount, Generation
from .services import InsufficientCredits, reserve_generation, reservegeneration_unsafe


class ConcurrencyTests(TransactionTestCase):
    def setUp(self):
        if connection.vendor != "postgresql":
            self.skipTest("这些并发测试仅在PostgreSQL数据库上运行。")

        self.user = get_user_model().objects.create_user(username="parallel-reader")
        self.account = CreditAccount.objects.create(user=self.user, balance=1)

    def run_two(self, action):
        def worker():
            close_old_connections()
            try:
                return action()
            finally:
                connections.close_all()

        with ThreadPoolExecutor(max_workers=2) as pool:
            futures = [pool.submit(worker) for _ in range(2)]
            return [future.result(timeout=15) for future in futures]
这个测试类在每次进行并发测试之前都会创建一个全新的账户。 当连接的数据库类型不是PostgreSQL时,setUp()方法会跳过相应的测试。随后系统会创建一个用户,并为该用户创建一个余额为1的账户。TransactionTestCase类能够模拟真实的交易流程,这与普通的TestCase不同——后者周围的交易操作可能会掩盖与锁机制相关的问题。 如果在SQLite数据库上执行测试,则无法验证PostgreSQL数据库的锁机制行为。 run_two()辅助方法会将相同的操作分配给两个工作线程来执行。 ThreadPoolExecutor(max_workers=2)用于创建两个工作线程,每个线程都会被调度去执行一次操作。future.result(timeout=15)用于获取该操作的最终结果;如果操作失败,该方法会抛出异常。所有工作线程在完成操作后都会关闭自己使用的连接,即使操作出现了错误也是如此。 这样的设计确保了两个操作可以通过不同的数据库连接来同时进行。

验证不安全的行为

请将以下方法添加到`ConcurrencyTests`类中,使其与`run_two()`方法处于相同的缩进级别:

def test_reproduce_unsafe_spending(self): both_have_read = Barrier(2) original_get = CreditAccount.objects.get def read_then_wait(*args, **kwargs): account = original_get(*args, **kwargs) both_have_read.wait(timeout=5) return account with patch( "credits.services.CreditAccount.objects.get", side_effect=read_then_wait, ): self.run_two( lambda: reservegeneration_unsafe(self.user.pk, "A garden").pk ) self.account.refresh_from_db() self.assertEqual(self.account.balance, 0) self.assertEqual(Generation.objects.count(), 2)

这个测试会强制要求这两个不安全的操作在继续执行之前先进行读取操作。

original_get()`方法会执行真正的数据库查询操作,而read_then_wait()方法会在调用它之后等待Barrier(2)信号;只有当两个操作都完成时,这个屏障才会解除阻塞,否则就会抛出异常。patch()方法会在测试期间暂时将原有的数据库查询逻辑替换为这个封装后的函数。

<在测试过程中,数据库的读取操作仍然是正常的,只是读操作之后的延迟时间被人为控制住了。

<最后的断言语句用于验证那些故意设置错误的测试结果。

<那些简短的lambda表达式会分别调用这两个不安全的函数,并为每个操作返回生成的记录的标识符;当两个操作都完成后,refresh_from_db()方法会重新加载账户信息。根据这些断言条件,在这个隔离环境下,账户的余额应该为零,同时应该存在两条生成记录。

<如果测试通过,那就说明能够成功重现这个错误现象,但这并不能证明那些不安全的函数本身的正确性。

检查受保护的接口端点

请将以下方法添加到同一个类中:

def concurrent_api_requests(self): ready = Barrier(2) def send_request(): client = APIClient() client.force_authenticate(self.user) ready.wait(timeout=5) return client.post( "/api/generate/", {"prompt": "A garden"}, format="json", ).status_code return self.run_two(send_request) def test_last_credit_accepts_only_one_request(self): self.assertEqual(sorted(self.concurrent_api_requests()), [201, 409]) self.account.refresh_from_db() self.assertEqual(self.account.balance, 0) self.assertEqual(Generation.objects.count(), 1) def test_two_credits.accept BOTHrequests(self): self.account.balance = 2 self.account.save(update_fields=["balance"]) self.assertEqual(self.concurrent_api_requests(), [201, 201]) self.account.refresh_from_db() self.assertEqual(self.account.balance, 0) self.assertEqual(Generation.objects.count(), 2)

请求辅助机制会通过受保护的视图发送两个已经过身份验证的请求。

每个工作线程都会创建自己的APIClient,而force_authenticate()函数会在不进行密码交换的情况下为测试用户提供认证服务。这个权限控制机制位于post()方法之前,因此两个工作线程会同时开始执行请求处理流程。每个请求处理完成后都会将其响应代码返回给run_two()函数。

这种测试方式旨在验证终端点的信用分配机制,而不会同时检测认证过程本身的正确性。

这两种测试方法能够覆盖“共享信用不足”以及“共享信用充足”的两种情况。

单信用额度测试会按照以下规则对测试结果进行排序:因为某个工作线程可能会先完成请求处理,所以预期会收到一个201状态码和一个409状态码;同时还会检查剩余信用额度是否为零,以及是否只生成了一条记录。而双信用额度测试则会改变初始的信用余额,并期望最终得到两次成功请求的结果、两条记录信息,以及零余额。

通过检查记录内容以及响应结果,可以及时发现那些被错误接受的请求处理结果。

权限控制机制负责协调请求的开始时间,但并不会对每一个数据库操作进行实时监控。

仍然有可能出现某个请求的处理速度比另一个快的情况。如果将权限控制机制设置在获取锁之后再启动请求处理流程,那么第一个工作线程就会被迫等待那个无法获取锁的工作线程。目前的实现方式能够避免这种人为导致的死锁现象,但并不能保证所有可能的执行顺序都能被正确处理。

在进行这项回归测试时,应结合有控制的复现实验以及已记录的数据库行为数据进行分析。

验证服务在遇到锁定情况时的表现

我们还可以测试预订功能是否会在检查余额之前先等待获取锁。

下面的测试案例中,初始信用额度被设置为零,因此在进行余额检查时,未受保护的读取操作会立即被拒绝。主连接会在启动任何工作线程之前先获取账户锁;随后该工作线程会调用预订功能,并设置1秒钟的锁超时时间。 将以下测试方法添加到ConcurrencyTests模块中:
    def testreservation_waits_for_account_lock(self):
        self.account.balance = 0
        self.account.save(update_fields=["balance"])
        user_id = self.user.pk

        def attempt_reservation():
            close_old_connections()
            try:
                with transaction.atomic():
                    with connection.cursor() as cursor:
                        cursor.execute("SET LOCAL lock_timeout = '1s'")
                        reserve_generation(user_id, "A garden")
                except OperationalError as error:
                    return error.__cause__.sqlstate
                except InsufficientCredits:
                    return "insufficient_credits"
                return "accepted"
            finally:
                connections.close_all()

        with ThreadPoolExecutor(max_workers=1) as pool:
            with transaction.atomic():
                CreditAccount.objects.select_for_update().get(pk=self.account.pk)
                result = pool.submit(attemptreservation).result(timeout=5)
                self.assertEqual(result, "55P03")

            result = pool.submit(attempt_reservation).result(timeout=5)
            self.assertEqual(result, "insufficient_credits")

        self.account.refresh_from_db()
        self.assertEqual(self.account.balance, 0)
        self.assertEqual(Generation.objects.count(), 0)

此测试用于检测在存在竞争锁的情况下系统的行为。

主连接会在等待工作进程的结果期间保持其事务处于开启状态。在工作进程的事务中,SET LOCAL命令会暂时缩短锁的超时时间。当锁的等待时间超过设定值后,工作进程应返回PostgreSQL的55P03错误代码,该代码表示“锁不可用”。

如果在这个阶段检测到余额为零的情况,说明该服务在进行检查之前并未等待账户锁被释放。

第二次调用会在主事务释放锁之后再次执行相同的操作。

这一次,工作进程应该能够获取账户锁并读取到零余额的值。此时它应返回insufficient_credits错误代码,而不是数据库错误信息。最终的验证结果应确认,在两次尝试过程中,账户的余额或生成记录都没有发生任何变化。

通过这两次调用,我们可以检测系统在存在竞争锁的情况下是如何进行锁的获取与释放操作的,而无需依赖两次请求恰好同时发生的情况。

设置账户余额为零是故意为之,这样可以使测试更加具有针对性。

如果我们使用1单位的信用额度,某些未加保护的函数在尝试更新数据时仍可能会遇到锁冲突的问题;而当信用额度为0时,这些函数会在任何更新操作之前就拒绝请求,从而导致我们的测试失败。因此,这个测试旨在检测服务在进行检查之前是否一定会先获取锁;而之前的测试则主要用于验证服务的消费行为。

这项关于锁控制的测试已在配套项目的PostgreSQL测试套件中通过验证。

失败后的回滚检查

在ConcurrencyTests目录之外,还需要添加另一个测试类:

class CreditRollbackTests(TransactionTestCase):
    def test_failed_record_creation_restores_credit(self):
        user = get_user_model().objects.create_user(username="rollback-reader")
        account = CreditAccount.objects.create(user=user, balance=1)

        with patch(
            "credits.servicesGeneration.objects.create",
            side_effect=RuntimeError("模拟记录创建失败"),
        ):
            with self.assertRaises(RuntimeError):
                reservegeneration(user.pk, "A garden")

        account.refresh_from_db()
        self.assertEqual(account.balance, 1)
        self.assertEqual(Generationobjects.count(), 0)

此测试会在尝试创建生成记录时故意引发异常。

首先会创建一个拥有1单位信用额度的账户。通过使用patch修改代码后,Generation.objects.create()方法会抛出RuntimeError异常,而assertRaises()则能确保这个错误会导致程序退出。在事务被回滚之后,重新加载的账户信息应仍然显示拥有1单位信用额度,并且不存在任何生成记录。

这项测试旨在验证:当预留操作失败时,不会留下任何未偿还的债务或余额减少的情况。

请在PostgreSQL服务仍可正常运行的情况下运行这些测试:

python manage.py test credits -v 2

此命令会运行credits应用中的测试。

-v 2这个选项会让Django显示每一项测试及其结果。Django会创建一个专门的测试数据库,因此需要具备相应的权限才能创建该数据库。我们在本地容器中使用的用户具有这种权限,但如果是现有的数据库环境,则可能需要进行一些配置调整。

在正常的PostgreSQL环境下,应该会有五项测试通过。在实际使用这个示例之前,请先确保这些测试确实能够成功执行。

常见错误与后续操作

虽然这个示例已经对信用判断逻辑进行了保护,但在将其应用到实际情况时,还是有一些细节容易被忽略。

正确读取账户信息

如果在交易之前就获取了账户对象并继续使用它,可能会导致余额显示错误。在做出信用判断之前,该示例中的函数会通过select_for_update()来专门获取最新的账户信息。

当将这段代码应用到其他服务或接口中时,请务必保持这种操作顺序。通过传入用户标识符,可以确保该函数始终能够获取到最新的、已被锁定的账户数据。

将数据库作为共享协调点

禁用提交按钮确实可以有效减少意外点击,但其他客户端仍然可以通过其他方式发送请求。Python中的线程锁也只能在同一进程内协调代码的执行顺序。

如果同时运行多个应用程序进程,那么对信用数据的操作仍然需要在共享数据库中进行保护。除了这个接口之外,还需要关注其他涉及余额更新的操作,因为不能假设只有这个接口负责写入数据。

对于简单的计数器操作,可以考虑使用条件更新

行锁是一种常见的保护机制。但对于简单的扣款操作,Django也可以通过F()表达式,在一次数据库更新中同时完成余额条件的判断和扣款操作。

条件设置非常重要:无条件的扣款操作可能无法确保账户余额足够。如果还需要生成新的记录,那么应该将扣款操作和记录创建操作放在同一笔事务中完成。

在这里我们使用了显式的锁机制,因为这样可以让读操作、判断逻辑以及更新操作更加清晰易懂。一旦理解了哪些操作需要被保护,就可以进一步探索条件更新的功能了。

将并发请求与重试机制区分开来

在我们的示例中,两次提交操作被视为独立的请求。如果用户拥有足够的信用额度,那么这两次操作都应该能够成功完成。

但在重试机制中,情况就会有所不同。应用程序可能会接收到一个新的生成请求,但在这个请求被客户端接收到之前,系统可能已经丢失了相应的响应数据。如果客户端再次发送相同的请求,为了避免重复扣款,应该返回之前的结果而不进行第二次扣款操作。

这就需要确保操作的幂等性:即能够识别重复的操作,并避免对其再次应用相同的效果。常见的实现方法包括使用请求标识符、数据库的唯一性约束规则以及存储已处理的结果。仅依靠余额锁是无法判断操作是否重复的。

供应商故障应对计划

一旦预订操作完成,后续发生的供应商故障并不会自动恢复相应的信用额度。因此,你需要制定相应的策略来决定是重新尝试生成资源、退还费用,还是将这笔费用暂时保留待后续处理。

在实际应用中,如果应用程序在预留了信用额度但尚未开始执行相关任务时就意外终止,也需要采取相应的恢复措施。reserved记录确实为问题追踪提供了依据,但本教程并未实现持久化的作业队列或故障恢复机制。

在扩展这个示例时,请务必充分考虑这些潜在问题。防止并发情况下的过度支出是构建完善信用系统的重要环节。

总结与后续步骤

在本指南的开头,我们看到两个请求虽然都通过了余额检查并且消耗了相同的信用额度,但最终系统的余额仍为零,这使得供应商故障这一问题很容易被忽视。

我们重新模拟了这一过程,然后通过事务机制和行锁来保护相关的决策逻辑。我们还检查了资源生成次数,测试了两个请求都具有足够信用额度的情况,并添加了针对记录创建失败情况的回滚测试。

当你在自己的应用程序中实现类似的功能时,请注意以下几点:

  1. 明确数据必须满足的条件,例如:每次仅允许生成一个资源。

  2. 找出那些可能会导致不同请求修改相同数据的场景。

  3. 采取有效措施来保护决策结果以及相关的数据库变更操作。

  4. 在实际使用的数据库环境中测试各种并发操作及故障处理机制。

这一原则同样适用于在线商店中的共享额度管理或任何类似场景。只有当应用程序能够安全地根据这些规则来执行操作时,相应的检查机制才有实际意义。

参考资料

如需深入了解重试机制的相关内容,请参阅使用幂等API确保重试操作的安全性这篇文章。

相关文章

技术实践

Cloudflare的OHTTP网关会拒绝解密通过Cloudflare传入的请求。

Cloudflare已经启动了其OHTTP Gateway的封闭测试阶段。这个被设计用来为相关网站解密“匿名HTTP请求”的服务,其独特的设计理念在于:该服务不会解密那些来自Cloudflare Workers或通过Cloudflare进行代理传输的请求,因为这样做会导致客户端标识信息与请求内容落入同一方手中。 作者:Steef-Jan Wiggers

阅读全文
技术实践

如何衡量操作规范与其实际执行情况之间的符合程度

在 我之前的文章中 ,我探讨了“执行器独立性”这一概念:即不同的执行机制应当能够满足相同的操作规范要求。 所谓“执行器独立性”,指的是即使负责执行的机制发生了变化,操作规范本身仍然应该保持稳定不变。 这种理念为我们提供了一种有用的架构模型: 规范 ↓ 执行器 ↓ 证据 然而,这仍然留下了一个重要的问题:我们该如何判断某次执行所产生的结果是否真正符合这些规范要求呢? 这就是所谓的“合规性评估问题”。 从执行器的角度来看,某个部署过程可能已经成功完成,但实际上却可能违反了操作规范的要求; 某个修复机制也可能完成了所有计划中的操作,但最终仍会使系统状态超出可接受的范围内; 甚至一个恢复操作也可能在没

阅读全文
技术实践

如何使用NestJS的观察功能:开发者必备的可观测性实践指南

可观测性并不是一个新出现的问题,NestJS也绝不是第一个试图解决这个问题的生态系统。 但NestJS Observe之所以值得关注,是因为它提出了一个更为具体的问题:当可观测性系统能够理解其所监控的框架时,会发生什么呢? 在了解其实现机制之前,我们首先需要明确这个问题,以及为什么框架的上下文会对此产生重要影响。 可观测性问题 如果你曾经长期开发并维护过服务器应用程序,你肯定遇到过这样的情况:某个API莫名其妙地运行速度变慢了,但你却不知道原因何在。 你检查了日志,却发现没有任何明显的异常。数据库运行正常,也没有任何错误信息;在你的本地机器上,这个接口也能正常工作。 于是你添加了一些日志记录:

阅读全文
技术实践

如何打破人工智能编程代理的循环机制

你很可能已经看过这样的情景:在使用人工智能编码工具构建的应用程序中,出现了某些故障。你让这个工具去修复这些问题,它似乎也“修复”了它们,但实际上错误依然存在,或者又出现了新的问题。 于是你会说“还是不行”。工具会再次尝试修复。二十分钟后,你会发现代码中的错误更多了,可用的功能也更少了,而且完全不知道该如何让应用程序恢复到正常状态。 人们常常用“人工智能反而使问题更加严重”或者“工具陷入了无限循环的修复模式”这样的表述来描述这种情况。这并不是某个特定产品的缺陷,像Replit、Cursor、Claude Code、Base44这类工具都会出现同样的问题,因为这种故障的本质是系统性的。 本教程将解

阅读全文