通过一个实际的电子商务结账示例,学习如何识别 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 库存:1t4 读取库存:1 库存:1t5 检查: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 = 0t14 库存:0 库存:0t15 计算:1 - 1 = 0t16 库存: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:“找到此文档并减去
$amount
从
balance
字段——一步完成,无需先读取当前值。
条件更新:
[
'用户身份'
=>
$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 事务,并了解原子操作何时不足以满足需求。
参考





