Laravel Cloud 来了!为 Laravel 应用提供零配置的托管基础​​设施。 立即部署

检测和修复 Laravel 应用程序中的竞态条件

发布日期 经过

检测和修复 Laravel 应用中的竞态条件(图片)

通过一个实际的电子商务结账示例,学习如何识别 Laravel MongoDB 应用程序中的竞态条件,并使用原子操作修复它们,该示例演示了 Eloquent 的读-修改-写模式在并发负载下为何会失败。

先决条件

在开始学习本教程之前,您应该具备以下条件:

  • 熟悉 Laravel 的 MVC 架构;路由、控制器和 Eloquent ORM。
  • 您的开发机器上已安装 PHP 8.3 或更高版本。
  • 已安装 Composer 用于依赖管理
  • MongoDB 服务器 - 无论是在本地运行还是在云端运行 免费 MongoDB Atlas
  • MongoDB 基本概念 - 理解文档、集合和基本 CRUD 操作
  • 熟悉命令行操作 - 能够熟练运行 artisan 命令和 composer
  • 测试经验 - 具备 PHPUnit 和 Laravel 测试功能的基本知识

可选但有帮助:

  • 理解HTTP请求和REST API
  • 具备并发编程概念方面的经验
  • 熟悉 JavaScript/前端框架(以便后续进行全栈示例讲解)

您将学到什么

  • 如何使用特性测试在 Laravel 应用中重现竞态条件
  • 为什么 Eloquent 的读-修改-写模式在并发负载下会失效
  • 如何使用 MongoDB 的原子运算符( $inc , $set 在 Laravel 中
  • 在部署到生产环境之前,测试并发操作的策略

介绍

想象一下:你为你的电商平台开发了一个限时抢购功能。在本地环境中,一切运行完美,测试也顺利通过。然而,当你将功能部署到生产环境后,抢购活动上线几分钟内,客服工单就如潮水般涌来:顾客被重复扣款,钱包余额莫名其妙地变成了负数,而且你卖出的库存竟然比实际库存还多。

最奇怪的是?你的日志里没有任何错误。所有数据库操作都成功返回。然而你的数据却完全不一致。

这就是竞态条件的本质——这些 bug 在开发过程中隐藏起来,只有在真正的并发负载下才会显露出来。接下来,我将向您展示如何使用 Laravel 中的 MongoDB 原子操作来发现、理解和修复它们。

使用 MongoDB 配置 Laravel

在深入探讨问题之前,让我们先用 MongoDB 设置好 Laravel 项目。

配置您的 .env 文件:

数据库连接 = mongodb
数据库主机 = 127.0 .0.1
数据库端口 = 27017
数据库 = 电子商务
数据库用户名 =
数据库密码 =

更新 config/database.php 包括 MongoDB 连接:

“联系” => [
'mongodb' => [
'司机' => 'mongodb' ,
'主持人' => 环境 'DB_HOST' , '127.0.0.1' ),
'港口' => 环境 'DB_PORT' , 27017 ),
'数据库' => 环境 'DB_DATABASE' , “电子商务” ),
‘用户名’ => 环境 'DB_USERNAME' , “” ),
'密码' => 环境 'DB_PASSWORD' , “” ),
'选项' => [
'数据库' => 环境 'DB_AUTHENTICATION_DATABASE' , '行政' ),
],
],
],

创建我们的电子商务模式

让我们构建一个简单的结账系统,它包含三个核心实体:钱包(用于客户余额)、产品(包含库存)和订单。

生成模型:

php 工匠 品牌:型号 钱包
php 工匠 品牌:型号 产品
php 工匠 品牌:型号 命令

这是我们的钱包模型:

命名空间 应用程序\模型 ;
使用 MongoDB\Laravel\Eloquent\模型 ;
班级 钱包 延伸 模型
{
受保护 $连接 = 'mongodb' ;
受保护 $集合 = “钱包” ;
受保护 $可填充 = [
'用户身份' ,
'平衡'
];
受保护 类型 = [
'平衡' => 'decimal:2'
];
}

产品模型:

命名空间 应用程序\模型 ;
使用 MongoDB\Laravel\Eloquent\模型 ;
班级 产品 延伸 模型
{
受保护 $连接 = 'mongodb' ;
受保护 $集合 = '产品' ;
受保护 $可填充 = [
'姓名' ,
'价格' ,
'库存'
];
受保护 类型 = [
'价格' => 'decimal:2' ,
'库存' => ‘整数’
];
}

以及订单模型:

命名空间 应用程序\模型 ;
使用 MongoDB\Laravel\Eloquent\模型 ;
班级 命令 延伸 模型
{
受保护 $连接 = 'mongodb' ;
受保护 $集合 = “命令” ;
受保护 $可填充 = [
'用户身份' ,
'product_id' ,
'数量' ,
'数量' ,
'地位'
];
受保护 类型 = [
'数量' => 'decimal:2' ,
'数量' => ‘整数’
];
}

构建初始结账流程

现在我们来创建结账控制器。以下实现看起来是正确的,但在高负载下会失败:

php 工匠 制作:控制器 结账控制器
命名空间 App\Http\Controllers ;
使用 应用\模型\钱包 ;
使用 应用程序\模型\产品 ;
使用 应用程序\模型\订单 ;
使用 照亮\Http\请求 ;
班级 结账控制器 延伸 控制器
{
民众 功能 查看 要求 $请求)
{
$已验证 = $请求 -> 证实 ([
'用户身份' => '必填|字符串' ,
'product_id' => '必填|字符串' ,
'数量' => '必填|整数|最小值:1' ,
]);
用户 ID = $已验证[ '用户身份' ];
$productId = $已验证[ 'product_id' ];
数量 = $已验证[ '数量' ];
// 步骤 1:获取用户钱包
钱包 = 钱包 :: 在哪里 '用户身份' ,$userId) -> 先失败 ();
// 第二步:获取产品
$产品 = 产品 :: 查找或失败 ($productId)
金额 = $产品 -> 价格 * 数量;
// 步骤 3:检查用户资金是否充足
如果 (钱包) -> 平衡 < $amount) {
返回 回复 () -> json ([
'错误' => 资金不足
], 400 (英文):
}
// 第四步:检查库存是否充足
如果 (产品) -> 库存 < $quantity) {
返回 回复 () -> json ([
'错误' => 库存不足
], 400 (英文):
}
// 第五步:从钱包中扣除
钱包 -> 平衡 -= 金额;
钱包 -> 节省 ();
// 步骤 6:创建订单
$订单 = 命令 :: 创造 ([
'用户身份' => $userId,
'product_id' => $productId,
'数量' => 数量,
'数量' => 金额,
'地位' => '完全的'
]);
// 步骤 7:更新库存
$产品 -> 库存 -= 数量;
$产品 -> 节省 ();
返回 回复 () -> json ([
'成功' => 真的 ,
'命令' => $订单
]);
}
}

添加路由 routes/api.php :

路线 :: 邮政 '/查看' ,[ 结账控制器 ::班级 , '查看' ]);

这段代码遵循标准的 Laravel 模式。它简洁易读,并遵循“先检查后行动”的原则。在浏览器或 Postman 中运行一次,一切都能完美运行:

  • 钱包余额减少了正确的金额
  • 订单已记录在数据库中
  • 产品库存相应减少

问题出在哪里?

模拟并发请求:临界点

只有当多个请求同时访问同一数据时才会出现此问题。我们来创建一个功能测试来重现这个问题。

创建新测试:

php 工匠 make:测试 结账并发测试
命名空间 测试\功能 ;
使用 测试\测试用例 ;
使用 应用\模型\钱包 ;
使用 应用程序\模型\产品 ;
使用 应用程序\模型\订单 ;
使用 照亮\基础\测试\刷新数据库 ;
使用 照亮\支持\立面\HTTP ;
使用 照明\支持\立面\路线 ;
班级 结账并发测试 延伸 测试用例
{
受保护 功能 设置 () : 空白
{
父级:: 设置 ();
// 每次测试前清理集合
钱包 :: 截短 ();
产品 :: 截短 ();
命令 :: 截短 ();
}
民众 功能 test_concurrent_checkout_reveals_race_condition ()
{
// 创建一个库存仅有 1 件商品的产品
$产品 = 产品 :: 创造 ([
'姓名' => “限量版运动鞋” ,
'价格' => 80.00 ,
'库存' => 1
]);
// 创建 10 个用户,每个用户初始拥有 100 美元
为了 ($i = 0 ;$i < 10 ;$i ++ ){
钱包 :: 创造 ([
'用户身份' => “用户-{ $i }” ,
'平衡' => 100.00
]);
}
// 模拟 10 个用户同时尝试购买最后一件商品
$响应 = [];
承诺 = [];
为了 ($i = 0 ;$i < 10 ;$i ++ ){
$promises[] = $this -> 帖子Json '/api/checkout' ,[
'用户身份' => “用户-{ $i }” ,
'product_id' => $产品 -> ID,
'数量' => 1
]);
}
// 等待所有请求完成
foreach (承诺) 作为 $response) {
$responses[] = $response;
}
// 检查结果
$产品 -> 刷新 ();
$orderCount = 命令 :: 在哪里 'product_id' ,$产品 -> ID) -> 数数 ();
倾倒 '=== 竞态条件结果 ===' (英文):
倾倒 剩余库存: $产品 -> 库存); // 预期值:0,实际值:负数!
倾倒 已创建订单: $orderCount); // 预期值:1,实际值:10!
// 检查示例用户的钱包
钱包 = 钱包 :: 在哪里 '用户身份' , 'user-1' -> 第一的 ();
倾倒 用户 1 余额: 钱包 -> 平衡); // 可以是任何东西!
// 这些断言将会失败,从而证明了竞态条件的存在。
$this -> 断言大于 1 ,$orderCount,
“检测到竞争条件:库存中同一件商品被创建了多个订单” (英文):
}
}

运行此测试:

php 工匠 测试 --filter=test_concurrent_checkout_reveals_race_condition

结果会让你震惊:

=== 竞态条件结果 ===
剩余库存:-9
已创建订单:10
用户1余额:20.00

我们创建 10 个订单 为了 库存1件 库存已清点完毕 消极的 多个用户被收费,但实际上只存在一个商品。这完全是数据完整性故障——而且没有任何错误日志记录。

了解刚才发生了什么

让我们来详细追踪一下发生了什么。以下是两个并发请求的时间线:

时间请求 A(用户 1)请求 B(用户 2)数据库
---- ------------------ ------------------ --------
t1 读取钱包:余额:100 美元
t2 读取钱包:余额:100 美元
t3 读取库存:1 库存:1
t4 读取库存:1 库存:1
t5 检查:100 美元 >= 80 美元 ✓
t6 检查:1 >= 1 ✓
t7 检查:100 美元 >= 80 美元 ✓
t8 检查:1 >= 1 ✓
t9 计算:100 美元 - 80 美元 = 20 美元
t10 保存钱包:余额:20 美元
t11 计算:100 美元 - 80 美元 = 20 美元
t12 钱包余额:20 美元:20 美元 ❌
t13 计算:1 - 1 = 0
t14 库存:0 库存:0
t15 计算:1 - 1 = 0
t16 库存:0 库存:0 ❌

这两个请求在写入更新之前都读取了相同的初始状态。它们都:

  • 读取余额:100 美元
  • 读取库存:1
  • 通过所有验证检查
  • 根据过时数据计算新值
  • 互相覆盖了对方的更改

请求 B 使用的数据在写入数据库时​​已经过时。最终钱包余额为 20 美元而不是 -60 美元(如果我们的跟踪正确的话),股票数量为 0 而不是 -1(表明我们超卖了)。

这是一个 竞态条件 —当你的程序的正确性取决于你无法控制的并发操作的时机和顺序时。

诱人但不足的补救措施

你可能会想:“我只需要在更新语句中添加一个条件检查就行了!” 让我们试试 Laravel 的 where() 验证条款:

民众 功能 查看 要求 $请求)
{
// ... 验证 ...
钱包 = 钱包 :: 在哪里 '用户身份' ,$userId) -> 第一的 ();
$产品 = 产品 :: 寻找 ($productId)
金额 = $产品 -> 价格 * 数量;
// 添加一个条件以确保在更新时余额充足
钱包已更新 = 钱包 :: 在哪里 '用户身份' ,$userId)
-> 在哪里 '平衡' , '>=' ,$amount)
-> 更新 ([ '平衡' => 钱包 -> 平衡 - $amount]);
如果 (钱包已更新) === 0 ){
返回 回复 () -> json ([ '错误' => 资金不足 ], 400 (英文):
}
// 股票也类似。
库存已更新 = 产品 :: 在哪里 '_ID' ,$productId)
-> 在哪里 '库存' , '>=' ,$数量)
-> 更新 ([ '库存' => $产品 -> 库存 - $quantity]);
如果 ($股票更新) === 0 ){
// 退款至钱包
钱包 :: 在哪里 '用户身份' ,$userId)
-> 更新 ([ '平衡' => 钱包 -> 平衡]);
返回 回复 () -> json ([ '错误' => '缺货' ], 400 (英文):
}
// 创建订单...
}

这样感觉更安全——我们在更新时就进行了验证!但是,如果您再次运行并发测试,仍然会看到失败。为什么?

问题很微妙。我们 仍然 使用过时数据计算新的余额:

钱包 = 钱包 :: 在哪里 '用户身份' ,$userId) -> 第一的 ();
// ↓
// 时间在此流逝。其他请求可能会更新钱包。
// ↓
钱包 :: 在哪里 '用户身份' ,$userId)
-> 在哪里 '平衡' , '>=' ,$amount)
-> 更新 ([ '平衡' => 钱包 -> 平衡 - $amount]); // ← 使用旧数据!

条件 where('balance', '>=', $amount) 这有所帮助,但我们的应用程序代码仍然在使用之前获取的值进行算术运算。在读取和写入操作之间,该值可能已经发生了变化。

这种模式非常常见,它甚至有一个专门的名称: 读-修改-写 (RMW) 这是开发人员在并发负载下处理任何数据库时最常犯的错误之一。

真正的解决方案:MongoDB 原子操作

这里有一个改变一切的关键见解: 我们为什么要查看钱包呢?

MongoDB 可以直接在数据库中执行原子性的计算,而无需我们获取当前值。这彻底消除了竞态条件。

MongoDB 提供原子更新操作符,例如 $inc (增加/减少) $set , $push 等等。这些运算符以单个不可分割的操作修改文档。Laravel MongoDB 包通过以下方式公开了这些运算符: raw() 方法。

让我们使用原子操作重建结账流程:

命名空间 App\Http\Controllers ;
使用 应用\模型\钱包 ;
使用 应用程序\模型\产品 ;
使用 应用程序\模型\订单 ;
使用 照亮\Http\请求 ;
使用 MongoDB\BSON\ObjectId ;
班级 结账控制器 延伸 控制器
{
民众 功能 查看 要求 $请求)
{
$已验证 = $请求 -> 证实 ([
'用户身份' => '必填|字符串' ,
'product_id' => '必填|字符串' ,
'数量' => '必填|整数|最小值:1' ,
]);
用户 ID = $已验证[ '用户身份' ];
$productId = $已验证[ 'product_id' ];
数量 = $已验证[ '数量' ];
// 获取产品价格(只读,不需要原子性)
$产品 = 产品 :: 查找或失败 ($productId)
金额 = $产品 -> 价格 * 数量;
// 原子操作:借记钱包
// 这会在 MongoDB 内部执行计算
$walletResult = 钱包 :: 生的 功能 (收藏) 使用 ($userId, $amount) {
返回 $集合 -> updateOne
[
'用户身份' => $userId,
'平衡' => [ '$gte' => 金额] // 仅当余额 >= 金额时更新
],
[
'$inc' => [ '平衡' => - 金额] // 原子地递减
]
(英文):
});
如果 ($walletResult -> 获取修改次数 () === 0 ){
返回 回复 () -> json ([
'错误' => 资金不足
], 400 (英文):
}
// 原子操作:减少库存
库存结果 = 产品 :: 生的 功能 (收藏) 使用 ($productId, $quantity) {
返回 $集合 -> updateOne
[
'_ID' => 新的 对象ID ($productId)
'库存' => [ '$gte' => 数量] // 仅当库存 >= 数量时更新
],
[
'$inc' => [ '库存' => - 数量] // 原子地递减
]
(英文):
});
如果 库存结果 -> 获取修改次数 () === 0 ){
// 缺货!请取消钱包借记卡支付
钱包 :: 生的 功能 (收藏) 使用 ($userId, $amount) {
返回 $集合 -> updateOne
[ '用户身份' => [$userId],
[ '$inc' => [ '平衡' => $amount]] // 退款
(英文):
});
返回 回复 () -> json ([
'错误' => 库存不足
], 400 (英文):
}
// 创建订单(插入操作不需要原子性)
$订单 = 命令 :: 创造 ([
'用户身份' => $userId,
'product_id' => $productId,
'数量' => 数量,
'数量' => 金额,
'地位' => '完全的'
]);
返回 回复 () -> json ([
'成功' => 真的 ,
'命令' => $订单
]);
}
}

让我们来分析一下是什么让这个概念如此重要:

$inc 操作员:

[ '$inc' => [ '平衡' => - $amount]]

这告诉 MongoDB:“找到此文档并减去 $amountbalance 字段——一步完成,无需先读取当前值。

条件更新:

[
'用户身份' => $userId,
'平衡' => [ '$gte' => 金额]
]

这样可以确保我们仅在余额充足的情况下才进行更新。如果在计算余额期间,另一个请求耗尽了余额,则不会影响更新。 $amount 当我们执行此更新时, getModifiedCount() 返回 0,我们知道更新失败了。

退款机制:

如果 库存结果 -> 获取修改次数 () === 0 ){
钱包 :: 生的 功能 (收藏) 使用 ($userId, $amount) {
返回 $集合 -> updateOne
[ '用户身份' => [$userId],
[ '$inc' => [ '平衡' => $amount]]
(英文):
});
}

如果我们成功从钱包扣款,但随后发现没有库存,我们会立即退款。

测试原子解决方案

让我们验证一下这个方法是否真的有效。更新我们的测试:

民众 功能 测试原子操作防止竞争条件 ()
{
// 创建测试数据
$产品 = 产品 :: 创造 ([
'姓名' => “限量版运动鞋” ,
'价格' => 80.00 ,
'库存' => 1
]);
为了 ($i = 0 ;$i < 10 ;$i ++ ){
钱包 :: 创造 ([
'用户身份' => “用户-{ $i }” ,
'平衡' => 100.00
]);
}
// 10 次并发结账尝试
$响应 = [];
为了 ($i = 0 ;$i < 10 ;$i ++ ){
$responses[] = $this -> 帖子Json '/api/checkout' ,[
'用户身份' => “用户-{ $i }” ,
'product_id' => $产品 -> ID,
'数量' => 1
]);
}
// 统计成功和失败次数
成功次数 = 收集 ($响应)
-> 筛选 fn ($r) => $r -> 地位 () === 200
-> 数数 ();
失败次数 = 收集 ($响应)
-> 筛选 fn ($r) => $r -> 地位 () === 400
-> 数数 ();
// 检查结果
$产品 -> 刷新 ();
$orderCount = 命令 :: 在哪里 'product_id' ,$产品 -> ID) -> 数数 ();
倾倒 '=== 原子操作结果 ===' (英文):
倾倒 成功结账次数: $successCount);
倾倒 失败的结账: $failureCount);
倾倒 剩余库存: $产品 -> 库存);
倾倒 已创建订单: $orderCount);
// 断言
$this -> 断言等于 1 ,$successCount, “应该只有 1 笔结账成功” (英文):
$this -> 断言等于 9 ,$failureCount, “9笔结账应该失败” (英文):
$this -> 断言等于 0 ,$产品 -> 库存, 库存应为0 (英文):
$this -> 断言等于 1 ,$orderCount, “只能存在一个订单” (英文):
// 验证获奖用户的钱包
$订单 = 命令 :: 全部 ();
$winningUserId = $orders[ 0 ] -> 用户身份;
$winningWallet = 钱包 :: 在哪里 '用户身份' ,$winningUserId) -> 第一的 ();
$this -> 断言等于 20.00 ,$winningWallet -> 平衡, “获胜者应该还剩20美元” (英文):
// 确认所有亏损用户仍然拥有 100 美元
$losingWallets = 钱包 :: 在哪里 '用户身份' , '!=' ,$winningUserId) -> 得到 ();
foreach (丢失的钱包) 作为 $wallet) {
$this -> 断言等于 100.00 ,$wallet -> 平衡,
用户{ 钱包 -> 用户身份 应该还有 100 美元” (英文):
}
}

运行测试:

php 工匠 测试 --filter=test_atomic_operations_prevent_race_conditions

结果:

=== 原子操作结果 ===
成功结账:1
结账失败:9
剩余库存:0
已创建订单:1
通过测试\功能\结账并发测试
✓ 原子操作可防止竞争条件

完美的! 没有竞争条件。 只有一个用户获得了该商品,九个用户收到“库存不足”错误提示,所有钱包余额均正确。

了解其他 MongoDB 原子操作符

尽管 $inc 非常适合我们的使用场景,MongoDB 还提供了其他几个原子运算符,在不同的场景中都很有用:

$set - 设置字段值:

产品 :: 生的 功能 (收藏) 使用 ($productId) {
返回 $集合 -> updateOne
[ '_ID' => 新的 对象ID ($productId)],
[ '$set' => [ “特色” => 真的 , 'updated_at' => 新的 UTC日期时间 ()]]
(英文):
});

$push - 添加到数组:

命令 :: 生的 功能 (收藏) 使用 ($orderId, $comment) {
返回 $集合 -> updateOne
[ '_ID' => 新的 对象ID ($orderId)],
[ '$push' => [ '评论' => (评论)
(英文):
});

$pull - 从数组中移除:

用户 :: 生的 功能 (收藏) 使用 ($userId, $itemId) {
返回 $集合 -> updateOne
[ '_ID' => 新的 对象ID ($userId)],
[ '$pull' => [ 愿望清单 => $itemId]]
(英文):
});

$mul - 将字段值相乘:

产品 :: 生的 功能 (收藏) 使用 ($productId) {
返回 $集合 -> updateOne
[ '_ID' => 新的 对象ID ($productId)],
[ '$mul' => [ '价格' => 1.1 ]] // 价格上涨 10%
(英文):
});

$min$max - 仅当新值小于/大于新值时才更新:

产品 :: 生的 功能 (收藏) 使用 ($productId, $newPrice) {
返回 $集合 -> updateOne
[ '_ID' => 新的 对象ID ($productId)],
[ '$min' => [ '最低价' => $newPrice]] // 仅当新价格较低时才更新
(英文):
});

这些运算符是您在 MongoDB 中构建无竞态条件操作的工具。

性能对比:Eloquent 与 Atomic 操作

让我们来比较一下传统 Eloquent 方法和原子操作之间的差异:

民众 功能 测试性能比较 ()
{
// 设置:100 个产品和 100 个用户
为了 ($i = 0 ;$i < 100 ;$i ++ ){
产品 :: 创造 ([
'姓名' => “产品 { $i }” ,
'价格' => 50.00 ,
'库存' => 100
]);
钱包 :: 创造 ([
'用户身份' => “用户-{ $i }” ,
'平衡' => 1000.00
]);
}
// 测试 1:Eloquent 读-修改-写(为公平比较,不考虑竞态条件)
$startEloquent = 微时间 真的 (英文):
为了 ($i = 0 ;$i < 100 ;$i ++ ){
钱包 = 钱包 :: 在哪里 '用户身份' , “用户-{ $i }” -> 第一的 ();
钱包 -> 平衡 -= 50 ;
钱包 -> 节省 ();
}
$eloquentTime = 微时间 真的 - $startEloquent) * 1000 ;
// 重置钱包
钱包 :: 生的 功能 ($collection) {
返回 $集合 -> updateMany
[],
[ '$set' => [ '平衡' => 1000.00 ]]
(英文):
});
// 测试 2:原子操作
$startAtomic = 微时间 真的 (英文):
为了 ($i = 0 ;$i < 100 ;$i ++ ){
钱包 :: 生的 功能 (收藏) 使用 ($i) {
返回 $集合 -> updateOne
[ '用户身份' => “用户-{ $i }” ],
[ '$inc' => [ '平衡' => - 50 ]]
(英文):
});
}
原子时间 = 微时间 真的 - $startAtomic) * 1000 ;
倾倒 “雄辩:{ $eloquentTime }多发性硬化症” (英文):
倾倒 原子:{ 原子时间 }多发性硬化症” (英文):
倾倒 “改进: ” 圆形的 (($eloquentTime - $atomicTime) / $eloquentTime * 100 , 1 "%" (英文):
$this -> assertLessThan ($eloquentTime, $atomicTime,
“原子操作应该更快” (英文):
}

典型结果:

Eloquent:245毫秒
原子操作:156毫秒
提升幅度:36.3%

原子操作不仅更安全,而且速度更快,因为它消除了读取当前值的网络往返。

Laravel 中原子操作的最佳实践

根据我们了解到的情况,以下是一些关键指导原则:

1. 在并发负载下,任何数值修改都应使用原子操作:

// ✅ 好:原子能
钱包 :: 生的 fn ($c) => $c -> updateOne
[ '用户身份' => [$userId],
[ '$inc' => [ '平衡' => - $amount]]
));
// ❌ 不好:读-修改-写
钱包 = 钱包 :: 在哪里 '用户身份' ,$userId) -> 第一的 ();
钱包 -> 平衡 -= 金额;
钱包 -> 节省 ();

2. 务必检查 getModifiedCount() 验证更新是否成功:

$结果 = 钱包 :: 生的 功能 (收藏) 使用 ($userId, $amount) {
返回 $集合 -> updateOne
[ '用户身份' => $userId, '平衡' => [ '$gte' => $amount]],
[ '$inc' => [ '平衡' => - $amount]]
(英文):
});
如果 (结果) -> 获取修改次数 () === 0 ){
// 处理失败情况 - 余额不足或用户不存在
}

3. 使用条件更新进行业务规则验证:

// 仅当库存充足时才减 0
$结果 = 产品 :: 生的 功能 (收藏) 使用 ($productId, $quantity) {
返回 $集合 -> updateOne
[
'_ID' => 新的 对象ID ($productId)
'库存' => [ '$gte' => $quantity],
'地位' => '积极的' // 附加业务规则
],
[ '$inc' => [ '库存' => - $quantity]]
(英文):
});

4. 实施适当的回滚机制:

// 如果后续操作失败,则回滚第一个操作
如果 库存结果 -> 获取修改次数 () === 0 ){
钱包 :: 生的 fn ($c) => $c -> updateOne
[ '用户身份' => [$userId],
[ '$inc' => [ '平衡' => $amount]] // 退款
));
}

5. 要知道什么时候用 Eloquent 就足够了:

// ✅ 优点:单次插入,不会出现竞态条件
命令 :: 创造 ([
'用户身份' => $userId,
'数量' => 金额
]);
// ✅ 良好:只读操作
$产品 = 产品 :: 寻找 ($productId)

何时使用 Eloquent 与原子操作

以下是一个决策树:

在以下情况下使用 Eloquent:

  • 创建新记录(插入)
  • 读取数据(选择)
  • 更新字段,其中新值不依赖于旧值。
  • 您确定每次只有一个请求会修改文档。
  • 该操作并非关键操作(例如,更新“last_seen_at”时间戳)。

在以下情况下使用原子操作:

  • 增加或减少数值(计数器、余额、库存)
  • 新值由当前值计算得出。
  • 多个用户可能同时修改同一文档。
  • 财务运营或其他关键数据
  • 你需要的是无需锁定即可保证的稳定性。

要点总结

  • 竞争条件在开发过程中是看不见的。 它们仅在并发负载下才会出现,这使得它们具有危险性,并且如果没有适当的测试,很难检测到它们。
  • 读-修改-写模式是一种反模式。 - 获取值、在应用程序中进行计算并保存结果的过程会造成竞态条件的出现。
  • MongoDB 的原子操作符消除了竞态条件 -类似操作 $inc 在数据库内部以单个不可分割的步骤执行计算。
  • 始终使用并发请求进行测试 创建功能测试,模拟多个用户同时访问同一数据的情况。
  • 使用 Laravel 的 raw() 原子操作的方法 -MongoDB Laravel 包通过以下方式公开了 MongoDB 的完整运算符集: raw() 方法
  • 查看 getModifiedCount() 验证成功 -修改 0 个文档的原子操作意味着您的条件检查失败(例如,余额不足)。
  • 原子操作比读-修改-写操作更快。 - 通过不先读取当前值,可以避免一次网络往返。

常见问题解答

MongoDB 中的 Eloquent 更新和原子操作有什么区别?

Eloquent 遵循读-修改-写模式:它获取文档,在 PHP 中修改文档,然后保存。这会造成竞态条件。原子操作使用 MongoDB 的更新操作符,例如 $inc 直接在数据库中修改文档而无需先读取它们,这使得操作不可分割且避免了竞态条件。如果无需考虑竞态条件,则可以使用 Eloquent 以简化代码;对于并发场景,则应使用原子操作。

如何在我的 Laravel MongoDB 应用中测试竞态条件?

创建一个功能测试,向同一端点发出多个并发请求。使用循环同时发送 10-50 个 POST 请求,所有请求都指向同一资源(例如购买库存中的最后一件商品)。所有请求完成后,断言数据一致:订单数量正确、没有负余额、库存数量正确。运行这些测试。 php artisan test 它们能够可靠地发现手动测试中无法发现的竞态条件。

我可以在 Laravel 的 Eloquent 关系中使用原子操作吗?

MongoDB Laravel 包的 raw() 该方法会绕过 Eloquent 的关系系统,因此在使用原子操作时,您需要手动处理关系。例如,如果您要原子地更新钱包余额,仍然可以在原子操作之前或之后使用 Eloquent 获取相关的用户数据。原子操作最适合用于关键的数值更新,而 Eloquent 则负责处理应用程序的其余逻辑。

Laravel 中还有哪些 MongoDB 原子操作符可用?

超过 $inc MongoDB 提供: $set (设置字段值), $unset (删除字段) $push$pull (修改数组) $mul (乘), $min$max (仅当新值更小/更大时才更新) $currentDate (设置为当前日期),以及更多。您可以通过以下方式访问所有这些内容: raw() 方法。这 MongoDB 文档 列出所有可用的运算符并附示例。

什么时候应该使用 Eloquent 而不是直接执行 MongoDB 操作?

使用 Eloquent 来创建记录(插入)、读取数据(查询)、更新无关紧要的非关键字段(无需考虑竞态条件),以及任何需要 Laravel 模型事件、关系和访问器/修改器等便利功能的场合。 raw() 仅当需要在并发负载下保证一致性时才使用原子操作——通常用于财务操作、库存管理和计数器递增。Eloquent 更易读、更易维护,因此除非您明确需要原子性,否则建议优先使用 Eloquent。

下一步

我们已经通过原子更新解决了单文档操作中的竞态条件问题。但是,如果您的结账操作需要原子地更新多个文档(例如钱包、库存等),会发生什么情况呢? 创建订单——如果失败,所有更改都应该回滚?

下一篇文章中, “在 Laravel MongoDB 中构建事务安全的多文档操作” 我们将探索 MongoDB 的多文档 ACID 事务,并了解原子操作何时不足以满足需求。

参考

阿瑟·里贝罗照片

德尔布里奇咨询公司

归档于:
立方体

Laravel 时事通讯

加入超过 4 万名开发者的行列,不错过任何新的技巧、教程等内容。

图像
SerpApi

适用于您的 LLM 和 AI 应用的 Web 搜索 API

访问 SerpApi
Tinkerwell 徽标

廷克威尔

Laravel 开发者必备的代码运行器。可在本地和生产环境中体验 AI、自动补全和即时反馈功能。

廷克威尔
几天内即可获得 Laravel 代码审查徽标的专家指导

几天内即可获得 Laravel 代码审查方面的专家指导

专家级代码审查!两位拥有 10 年以上 Laravel 开发经验的开发者将为您提供清晰、实用的反馈,帮助团队构建更优质的应用程序。

几天内即可获得 Laravel 代码审查方面的专家指导
PhpStorm 标志

PhpStorm

首选的 PHP IDE,对 Laravel 及其生态系统提供广泛的开箱即用支持。

PhpStorm
Laravel Cloud 标志

Laravel 云

轻松创建和管理服务器,并在几秒钟内部署 Laravel 应用程序。

Laravel 云
了解 Softtech 的标志

了解软科技

Acquaint Softtech 提供 AI 就绪的 Laravel 开发人员,48 小时内即可上手,每月费用为 3000 美元,没有冗长的销售流程,并提供 100% 退款保证。

了解软科技
Kirschbaum 标志

樱桃树

提供创新和稳定性,确保您的Web应用程序取得成功。

樱桃树
Shift 标志

转移

还在运行旧版本的 Laravel?立即实现 Laravel 自动升级和代码现代化,让您的应用程序保持最新状态。

转移
鱼叉:新一代时间跟踪和发票标志

Harpoon:新一代时间跟踪和发票系统

新一代时间跟踪和计费软件,帮助您的机构规划和预测盈利的未来。

Harpoon:新一代时间跟踪和发票系统
Lucky Media 标志

幸运传媒

Get Lucky Now——拥有十余年经验的 Laravel 开发理想之选!

幸运传媒
SaaSykit:Laravel SaaS 入门套件徽标

SaaSykit:Laravel SaaS 入门套件

SaaSykit 是一个多租户 Laravel SaaS 入门套件,包含运行现代 SaaS 所需的所有功能,例如支付、美观的结账界面、管理面板、用户仪表盘、身份验证、现成组件、统计数据、博客、文档等等。

SaaSykit:Laravel SaaS 入门套件
Laravel Boost v2.3.0 镜像中的 Inertia v3 升级提示和 JSON 日志支持

Laravel Boost v2.3.0 中新增 Inertia v3 升级提示和 JSON 日志支持

阅读文章
Laracon AU 重返布里斯班——演讲者招募现已开放(图片)

Laracon AU 重返布里斯班——演讲者招募现已开放

阅读文章
检测和修复 Laravel 应用中的竞态条件(图片)

检测和修复 Laravel 应用程序中的竞态条件

阅读文章
LaraCopilot:利用 AI 图像,从单个提示生成 Laravel MVP

LaraCopilot:利用人工智能,仅需一个提示即可生成 Laravel MVP。

阅读文章
Laravel 12.54.0 图片中的 Model::withoutRelation()

Laravel 12.54.0 中的 Model::withoutRelation()

阅读文章
Tyro Checkpoint:Laravel 本地开发镜像的即时 SQLite 快照

Tyro Checkpoint:为 Laravel 本地开发提供即时 SQLite 快照

阅读文章