Browse Source

为物品消耗和冻结逻辑添加并发安全保护

- 在物品消耗逻辑中添加lockForUpdate锁定机制
- 在物品冻结逻辑中添加lockForUpdate锁定机制
- 锁定后重新验证数量,防止并发修改导致的数据不一致
- 包含统一属性物品和单独属性物品的锁定保护
- 添加消耗和冻结的并发安全性测试验证
- 提供详细的错误信息,区分锁定前后的数量变化
dongasai 6 months ago
parent
commit
d54a4fdb9e

+ 9 - 4
app/Module/GameItems/Logics/Item.php

@@ -350,21 +350,25 @@ class Item
         }
 
         // 获取用户物品(优先消耗冻结物品,然后按过期时间排序)
+        // 使用lockForUpdate锁定记录,防止并发修改
         if ($includeFrozen) {
             // 当包含冻结物品时,优先消耗冻结物品,再消耗未冻结物品
             $userItems = $query->orderBy('is_frozen', 'desc') // 冻结物品优先(true > false)
             ->orderBy('quantity', 'desc')// 数量倒序
                 ->orderBy('expire_at') // 然后按过期时间排序
+                ->lockForUpdate() // 锁定记录,防止并发修改
                 ->get();
         } else {
             // 只消耗未冻结物品时,按过期时间排序
-            $userItems = $query->orderBy('expire_at')->get();
+            $userItems = $query->orderBy('expire_at')
+                ->lockForUpdate() // 锁定记录,防止并发修改
+                ->get();
         }
 
-        // 检查物品数量是否足够
+        // 重新检查物品数量是否足够(锁定后可能已变化)
         $totalQuantity = $userItems->sum('quantity');
         if ($totalQuantity < $quantity) {
-            throw new Exception("用户 {$userId} 的物品 {$itemId} 数量不足,需要 {$quantity},实际 {$totalQuantity}");
+            throw new Exception("用户 {$userId} 的物品 {$itemId} 数量不足,需要 {$quantity},锁定后实际 {$totalQuantity}");
         }
 
         // 获取来源信息
@@ -492,7 +496,8 @@ class Item
             $query->where('is_frozen', false); // 只获取未冻结的物品
         }
 
-        $userItem = $query->first();
+        // 使用lockForUpdate锁定记录,防止并发修改
+        $userItem = $query->lockForUpdate()->first();
 
         if (!$userItem) {
             $frozenText = $includeFrozen ? '' : '(未冻结)';

+ 10 - 0
app/Module/GameItems/Logics/ItemFreeze.php

@@ -63,14 +63,22 @@ class ItemFreeze
         }
 
         // 获取用户可用的物品堆叠(按过期时间排序,优先冻结即将过期的)
+        // 使用lockForUpdate锁定记录,防止并发修改
         $availableItems = ItemUser::where('user_id', $userId)
             ->where('item_id', $itemId)
             ->where('is_frozen', false)
             ->whereNull('instance_id')
             ->where('quantity', '>', 0)
             ->orderBy('expire_at')
+            ->lockForUpdate() // 锁定记录,防止并发修改
             ->get();
 
+        // 重新验证可用数量(锁定后可能已变化)
+        $actualAvailableQuantity = $availableItems->sum('quantity');
+        if ($actualAvailableQuantity < $quantity) {
+            throw new Exception("用户 {$userId} 的物品 {$itemId} 可用数量不足,需要冻结 {$quantity},锁定后实际 {$actualAvailableQuantity}");
+        }
+
         $remainingQuantity = $quantity;
         $frozenItems = [];
         $changedItems = []; // 记录所有变更的物品,包括数量减少和冻结创建
@@ -232,10 +240,12 @@ class ItemFreeze
         Helper::check_tr();
 
         // 查找用户的单独属性物品
+        // 使用lockForUpdate锁定记录,防止并发修改
         $userItem = ItemUser::where('user_id', $userId)
             ->where('item_id', $itemId)
             ->where('instance_id', $instanceId)
             ->where('is_frozen', false)
+            ->lockForUpdate() // 锁定记录,防止并发修改
             ->first();
 
         if (!$userItem) {

+ 188 - 0
tests/manual_test_consume_concurrency.php

@@ -0,0 +1,188 @@
+<?php
+
+/**
+ * 手动测试脚本:验证物品消耗过程中的并发安全性
+ * 
+ * 测试lock for update是否能正确处理消耗操作的并发场景
+ */
+
+require_once __DIR__ . '/../vendor/autoload.php';
+
+use App\Module\GameItems\Services\ItemService;
+use App\Module\GameItems\Enums\FREEZE_REASON_TYPE;
+use Illuminate\Support\Facades\DB;
+
+// 启动Laravel应用
+$app = require_once __DIR__ . '/../bootstrap/app.php';
+$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
+
+echo "=== 物品消耗并发安全性测试 ===\n\n";
+
+try {
+    DB::beginTransaction();
+    
+    $userId = 1001;
+    $itemId = 1;
+    
+    echo "1. 准备测试数据\n";
+    
+    // 添加物品
+    $addResult = ItemService::addItem($userId, $itemId, 100);
+    echo "   添加物品: " . json_encode($addResult, JSON_UNESCAPED_UNICODE) . "\n";
+    
+    // 检查初始状态
+    echo "\n2. 检查初始物品状态\n";
+    $userItems = DB::table('item_users')
+        ->where('user_id', $userId)
+        ->where('item_id', $itemId)
+        ->get();
+    
+    foreach ($userItems as $item) {
+        $frozenStatus = $item->is_frozen ? '冻结' : '可用';
+        echo "   物品堆ID: {$item->id}, 数量: {$item->quantity}, 状态: {$frozenStatus}\n";
+    }
+    
+    // 测试正常消耗
+    echo "\n3. 测试正常消耗(锁定机制)\n";
+    $consumeResult1 = ItemService::consumeItem(
+        $userId, 
+        $itemId, 
+        null,
+        30, 
+        [
+            'include_frozen' => false,
+            'source_type' => 'test_consume_1',
+            'source_id' => 1001
+        ]
+    );
+    echo "   消耗30个成功: " . json_encode($consumeResult1, JSON_UNESCAPED_UNICODE) . "\n";
+    
+    // 检查消耗后状态
+    echo "\n4. 检查消耗后的物品状态\n";
+    $userItemsAfter = DB::table('item_users')
+        ->where('user_id', $userId)
+        ->where('item_id', $itemId)
+        ->get();
+    
+    foreach ($userItemsAfter as $item) {
+        $frozenStatus = $item->is_frozen ? '冻结' : '可用';
+        echo "   物品堆ID: {$item->id}, 数量: {$item->quantity}, 状态: {$frozenStatus}\n";
+    }
+    
+    // 测试边界情况:消耗全部剩余数量
+    echo "\n5. 测试边界情况:消耗全部剩余数量\n";
+    $consumeResult2 = ItemService::consumeItem(
+        $userId, 
+        $itemId, 
+        null,
+        70, 
+        [
+            'include_frozen' => false,
+            'source_type' => 'test_consume_2',
+            'source_id' => 1002
+        ]
+    );
+    echo "   消耗70个成功: " . json_encode($consumeResult2, JSON_UNESCAPED_UNICODE) . "\n";
+    
+    // 检查完全消耗后状态
+    echo "\n6. 检查完全消耗后的物品状态\n";
+    $userItemsFinal = DB::table('item_users')
+        ->where('user_id', $userId)
+        ->where('item_id', $itemId)
+        ->get();
+    
+    foreach ($userItemsFinal as $item) {
+        $frozenStatus = $item->is_frozen ? '冻结' : '可用';
+        echo "   物品堆ID: {$item->id}, 数量: {$item->quantity}, 状态: {$frozenStatus}\n";
+    }
+    
+    // 测试数量不足的情况
+    echo "\n7. 测试数量不足的情况\n";
+    try {
+        $consumeResult3 = ItemService::consumeItem(
+            $userId, 
+            $itemId, 
+            null,
+            10, 
+            [
+                'include_frozen' => false,
+                'source_type' => 'test_consume_3',
+                'source_id' => 1003
+            ]
+        );
+        echo "   意外成功: " . json_encode($consumeResult3, JSON_UNESCAPED_UNICODE) . "\n";
+    } catch (Exception $e) {
+        echo "   预期失败: " . $e->getMessage() . "\n";
+    }
+    
+    // 测试包含冻结物品的消耗
+    echo "\n8. 测试包含冻结物品的消耗\n";
+    
+    // 先添加更多物品并冻结一部分
+    $addResult2 = ItemService::addItem($userId, $itemId, 50);
+    echo "   添加50个物品\n";
+    
+    $freezeResult = ItemService::freezeItem(
+        $userId, 
+        $itemId, 
+        null,
+        20, 
+        FREEZE_REASON_TYPE::TRADE_ORDER->value,
+        [
+            'source_id' => 12345,
+            'source_type' => 'test_order',
+            'operator_id' => 1
+        ]
+    );
+    echo "   冻结20个物品\n";
+    
+    // 检查冻结后状态
+    echo "   冻结后物品状态:\n";
+    $userItemsWithFrozen = DB::table('item_users')
+        ->where('user_id', $userId)
+        ->where('item_id', $itemId)
+        ->where('quantity', '>', 0)
+        ->get();
+    
+    foreach ($userItemsWithFrozen as $item) {
+        $frozenStatus = $item->is_frozen ? '冻结' : '可用';
+        echo "     物品堆ID: {$item->id}, 数量: {$item->quantity}, 状态: {$frozenStatus}\n";
+    }
+    
+    // 消耗包含冻结物品
+    echo "   消耗包含冻结物品(优先消耗冻结物品):\n";
+    $consumeResult4 = ItemService::consumeItem(
+        $userId, 
+        $itemId, 
+        null,
+        35, 
+        [
+            'include_frozen' => true,
+            'source_type' => 'test_consume_4',
+            'source_id' => 1004
+        ]
+    );
+    echo "   消耗35个成功: " . json_encode($consumeResult4, JSON_UNESCAPED_UNICODE) . "\n";
+    
+    // 检查最终状态
+    echo "\n9. 检查最终物品状态\n";
+    $userItemsEnd = DB::table('item_users')
+        ->where('user_id', $userId)
+        ->where('item_id', $itemId)
+        ->get();
+    
+    foreach ($userItemsEnd as $item) {
+        $frozenStatus = $item->is_frozen ? '冻结' : '可用';
+        echo "   物品堆ID: {$item->id}, 数量: {$item->quantity}, 状态: {$frozenStatus}\n";
+    }
+    
+    echo "\n=== 测试完成 ===\n";
+    
+    DB::rollback();
+    echo "已回滚测试数据\n";
+    
+} catch (Exception $e) {
+    DB::rollback();
+    echo "测试失败: " . $e->getMessage() . "\n";
+    echo "堆栈跟踪: " . $e->getTraceAsString() . "\n";
+}

+ 206 - 0
tests/manual_test_freeze_concurrency.php

@@ -0,0 +1,206 @@
+<?php
+
+/**
+ * 手动测试脚本:验证物品冻结过程中的并发安全性
+ * 
+ * 测试lock for update是否能正确处理冻结操作的并发场景
+ */
+
+require_once __DIR__ . '/../vendor/autoload.php';
+
+use App\Module\GameItems\Services\ItemService;
+use App\Module\GameItems\Enums\FREEZE_REASON_TYPE;
+use Illuminate\Support\Facades\DB;
+
+// 启动Laravel应用
+$app = require_once __DIR__ . '/../bootstrap/app.php';
+$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
+
+echo "=== 物品冻结并发安全性测试 ===\n\n";
+
+try {
+    DB::beginTransaction();
+    
+    $userId = 1001;
+    $itemId = 1;
+    
+    echo "1. 准备测试数据\n";
+    
+    // 添加物品
+    $addResult = ItemService::addItem($userId, $itemId, 100);
+    echo "   添加物品: " . json_encode($addResult, JSON_UNESCAPED_UNICODE) . "\n";
+    
+    // 检查初始状态
+    echo "\n2. 检查初始物品状态\n";
+    $userItems = DB::table('item_users')
+        ->where('user_id', $userId)
+        ->where('item_id', $itemId)
+        ->get();
+    
+    foreach ($userItems as $item) {
+        $frozenStatus = $item->is_frozen ? '冻结' : '可用';
+        echo "   物品堆ID: {$item->id}, 数量: {$item->quantity}, 状态: {$frozenStatus}\n";
+    }
+    
+    // 测试正常冻结
+    echo "\n3. 测试正常冻结(锁定机制)\n";
+    $freezeResult1 = ItemService::freezeItem(
+        $userId, 
+        $itemId, 
+        null,
+        30, 
+        FREEZE_REASON_TYPE::TRADE_ORDER->value,
+        [
+            'source_id' => 12345,
+            'source_type' => 'test_order_1',
+            'operator_id' => 1
+        ]
+    );
+    echo "   冻结30个成功: " . json_encode($freezeResult1, JSON_UNESCAPED_UNICODE) . "\n";
+    
+    // 检查冻结后状态
+    echo "\n4. 检查冻结后的物品状态\n";
+    $userItemsAfter = DB::table('item_users')
+        ->where('user_id', $userId)
+        ->where('item_id', $itemId)
+        ->get();
+    
+    foreach ($userItemsAfter as $item) {
+        $frozenStatus = $item->is_frozen ? '冻结' : '可用';
+        echo "   物品堆ID: {$item->id}, 数量: {$item->quantity}, 状态: {$frozenStatus}\n";
+    }
+    
+    // 测试再次冻结
+    echo "\n5. 测试再次冻结剩余物品\n";
+    $freezeResult2 = ItemService::freezeItem(
+        $userId, 
+        $itemId, 
+        null,
+        40, 
+        FREEZE_REASON_TYPE::TRADE_ORDER->value,
+        [
+            'source_id' => 12346,
+            'source_type' => 'test_order_2',
+            'operator_id' => 1
+        ]
+    );
+    echo "   冻结40个成功: " . json_encode($freezeResult2, JSON_UNESCAPED_UNICODE) . "\n";
+    
+    // 检查再次冻结后状态
+    echo "\n6. 检查再次冻结后的物品状态\n";
+    $userItemsAfter2 = DB::table('item_users')
+        ->where('user_id', $userId)
+        ->where('item_id', $itemId)
+        ->get();
+    
+    foreach ($userItemsAfter2 as $item) {
+        $frozenStatus = $item->is_frozen ? '冻结' : '可用';
+        echo "   物品堆ID: {$item->id}, 数量: {$item->quantity}, 状态: {$frozenStatus}\n";
+    }
+    
+    // 测试边界情况:冻结全部剩余数量
+    echo "\n7. 测试边界情况:冻结全部剩余数量\n";
+    $freezeResult3 = ItemService::freezeItem(
+        $userId, 
+        $itemId, 
+        null,
+        30, 
+        FREEZE_REASON_TYPE::TRADE_ORDER->value,
+        [
+            'source_id' => 12347,
+            'source_type' => 'test_order_3',
+            'operator_id' => 1
+        ]
+    );
+    echo "   冻结30个成功: " . json_encode($freezeResult3, JSON_UNESCAPED_UNICODE) . "\n";
+    
+    // 检查完全冻结后状态
+    echo "\n8. 检查完全冻结后的物品状态\n";
+    $userItemsFinal = DB::table('item_users')
+        ->where('user_id', $userId)
+        ->where('item_id', $itemId)
+        ->get();
+    
+    foreach ($userItemsFinal as $item) {
+        $frozenStatus = $item->is_frozen ? '冻结' : '可用';
+        echo "   物品堆ID: {$item->id}, 数量: {$item->quantity}, 状态: {$frozenStatus}\n";
+    }
+    
+    // 测试数量不足的情况
+    echo "\n9. 测试数量不足的情况\n";
+    try {
+        $freezeResult4 = ItemService::freezeItem(
+            $userId, 
+            $itemId, 
+            null,
+            10, 
+            FREEZE_REASON_TYPE::TRADE_ORDER->value,
+            [
+                'source_id' => 12348,
+                'source_type' => 'test_order_4',
+                'operator_id' => 1
+            ]
+        );
+        echo "   意外成功: " . json_encode($freezeResult4, JSON_UNESCAPED_UNICODE) . "\n";
+    } catch (Exception $e) {
+        echo "   预期失败: " . $e->getMessage() . "\n";
+    }
+    
+    // 测试解冻后再冻结
+    echo "\n10. 测试解冻后再冻结\n";
+    
+    // 先解冻一个
+    $freezeLogId = $freezeResult1['frozen_items'][0]['freeze_log_id'];
+    $unfreezeResult = ItemService::unfreezeItem($freezeLogId);
+    echo "   解冻成功: " . json_encode($unfreezeResult, JSON_UNESCAPED_UNICODE) . "\n";
+    
+    // 检查解冻后状态
+    echo "   解冻后物品状态:\n";
+    $userItemsUnfrozen = DB::table('item_users')
+        ->where('user_id', $userId)
+        ->where('item_id', $itemId)
+        ->get();
+    
+    foreach ($userItemsUnfrozen as $item) {
+        $frozenStatus = $item->is_frozen ? '冻结' : '可用';
+        echo "     物品堆ID: {$item->id}, 数量: {$item->quantity}, 状态: {$frozenStatus}\n";
+    }
+    
+    // 再次冻结
+    echo "   再次冻结部分物品:\n";
+    $freezeResult5 = ItemService::freezeItem(
+        $userId, 
+        $itemId, 
+        null,
+        15, 
+        FREEZE_REASON_TYPE::TRADE_ORDER->value,
+        [
+            'source_id' => 12349,
+            'source_type' => 'test_order_5',
+            'operator_id' => 1
+        ]
+    );
+    echo "   冻结15个成功: " . json_encode($freezeResult5, JSON_UNESCAPED_UNICODE) . "\n";
+    
+    // 检查最终状态
+    echo "\n11. 检查最终物品状态\n";
+    $userItemsEnd = DB::table('item_users')
+        ->where('user_id', $userId)
+        ->where('item_id', $itemId)
+        ->get();
+    
+    foreach ($userItemsEnd as $item) {
+        $frozenStatus = $item->is_frozen ? '冻结' : '可用';
+        echo "   物品堆ID: {$item->id}, 数量: {$item->quantity}, 状态: {$frozenStatus}\n";
+    }
+    
+    echo "\n=== 测试完成 ===\n";
+    
+    DB::rollback();
+    echo "已回滚测试数据\n";
+    
+} catch (Exception $e) {
+    DB::rollback();
+    echo "测试失败: " . $e->getMessage() . "\n";
+    echo "堆栈跟踪: " . $e->getTraceAsString() . "\n";
+}