Browse Source

添加解冻过程中的并发安全保护

- 在补足转移操作中添加lockForUpdate锁定机制
- 防止并发场景下的竞态条件和数据不一致
- 锁定后重新验证可用数量,确保数据准确性
- 添加并发安全性测试验证修复效果
- 提供详细的错误信息和失败状态
dongasai 6 months ago
parent
commit
cfa88176f5
2 changed files with 237 additions and 3 deletions
  1. 49 3
      app/Module/GameItems/Logics/ItemFreeze.php
  2. 188 0
      tests/manual_test_unfreeze_concurrency.php

+ 49 - 3
app/Module/GameItems/Logics/ItemFreeze.php

@@ -348,15 +348,24 @@ class ItemFreeze
                 );
             }
 
-            // 从用户可用物品中扣除差额,补足到冻结堆
+            // 从用户可用物品中扣除差额,补足到冻结堆(使用锁定避免并发问题)
             $availableItems = ItemUser::where('user_id', $frozenItem->user_id)
                 ->where('item_id', $frozenItem->item_id)
                 ->where('is_frozen', false)
                 ->whereNull('instance_id')
                 ->where('quantity', '>', 0)
                 ->orderBy('expire_at')
+                ->lockForUpdate() // 锁定记录,避免并发修改
                 ->get();
 
+            // 重新验证可用数量(锁定后可能已变化)
+            $actualAvailableQuantity = $availableItems->sum('quantity');
+            if ($actualAvailableQuantity < $shortageQuantity) {
+                throw new Exception(
+                    "解冻失败:需要补足 {$shortageQuantity},但锁定后用户可用数量只有 {$actualAvailableQuantity}"
+                );
+            }
+
             $remainingShortage = $shortageQuantity;
             $transferDetails = []; // 记录转移详情
 
@@ -830,15 +839,33 @@ class ItemFreeze
                 ];
             }
 
-            // 从用户可用物品中补足全部数量
+            // 从用户可用物品中补足全部数量(使用锁定避免并发问题)
             $availableItems = ItemUser::where('user_id', $frozenItem->user_id)
                 ->where('item_id', $frozenItem->item_id)
                 ->where('is_frozen', false)
                 ->whereNull('instance_id')
                 ->where('quantity', '>', 0)
                 ->orderBy('expire_at')
+                ->lockForUpdate() // 锁定记录,避免并发修改
                 ->get();
 
+            // 重新验证可用数量(锁定后可能已变化)
+            $actualAvailableQuantity = $availableItems->sum('quantity');
+            if ($actualAvailableQuantity < $originalFrozenQuantity) {
+                return [
+                    'success' => false,
+                    'status' => 'insufficient_available_after_lock',
+                    'message' => "冻结物品已被完全消耗,锁定后用户可用数量不足以补足原始冻结数量",
+                    'user_id' => $frozenItem->user_id,
+                    'item_id' => $frozenItem->item_id,
+                    'instance_id' => $frozenItem->instance_id,
+                    'unfrozen_quantity' => 0,
+                    'original_frozen_quantity' => $originalFrozenQuantity,
+                    'available_quantity' => $actualAvailableQuantity,
+                    'shortage_quantity' => $originalFrozenQuantity - $actualAvailableQuantity,
+                ];
+            }
+
             $remainingQuantity = $originalFrozenQuantity;
             $transferDetails = []; // 记录转移详情
 
@@ -957,15 +984,34 @@ class ItemFreeze
                     ];
                 }
 
-                // 从用户可用物品中扣除差额,补足到冻结堆
+                // 从用户可用物品中扣除差额,补足到冻结堆(使用锁定避免并发问题)
                 $availableItems = ItemUser::where('user_id', $frozenItem->user_id)
                     ->where('item_id', $frozenItem->item_id)
                     ->where('is_frozen', false)
                     ->whereNull('instance_id')
                     ->where('quantity', '>', 0)
                     ->orderBy('expire_at')
+                    ->lockForUpdate() // 锁定记录,避免并发修改
                     ->get();
 
+                // 重新验证可用数量(锁定后可能已变化)
+                $actualAvailableQuantity = $availableItems->sum('quantity');
+                if ($actualAvailableQuantity < $shortageQuantity) {
+                    return [
+                        'success' => false,
+                        'status' => 'insufficient_available_after_lock',
+                        'message' => "解冻失败:需要补足 {$shortageQuantity},但锁定后用户可用数量只有 {$actualAvailableQuantity}",
+                        'user_id' => $frozenItem->user_id,
+                        'item_id' => $frozenItem->item_id,
+                        'instance_id' => $frozenItem->instance_id,
+                        'unfrozen_quantity' => 0,
+                        'original_frozen_quantity' => $originalFrozenQuantity,
+                        'current_frozen_quantity' => $currentQuantity,
+                        'shortage_quantity' => $shortageQuantity,
+                        'available_quantity' => $actualAvailableQuantity,
+                    ];
+                }
+
                 $remainingShortage = $shortageQuantity;
                 $transferDetails = []; // 记录转移详情
 

+ 188 - 0
tests/manual_test_unfreeze_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;
+    $freezeQuantity = 100;
+    $consumeQuantity = 60;
+    
+    echo "1. 准备测试数据\n";
+    
+    // 添加物品(确保有足够的物品用于测试)
+    $addResult = ItemService::addItem($userId, $itemId, 200);
+    echo "   添加物品: " . json_encode($addResult, JSON_UNESCAPED_UNICODE) . "\n";
+    
+    // 冻结物品
+    echo "\n2. 冻结物品\n";
+    $freezeResult = ItemService::freezeItem(
+        $userId, 
+        $itemId, 
+        null,
+        $freezeQuantity, 
+        FREEZE_REASON_TYPE::TRADE_ORDER->value,
+        [
+            'source_id' => 12345,
+            'source_type' => 'test_order',
+            'operator_id' => 1
+        ]
+    );
+    $freezeLogId = $freezeResult['frozen_items'][0]['freeze_log_id'];
+    echo "   冻结成功,日志ID: {$freezeLogId}\n";
+    
+    // 消耗部分冻结物品
+    echo "\n3. 消耗部分冻结物品\n";
+    $consumeResult = ItemService::consumeItem(
+        $userId, 
+        $itemId, 
+        null,
+        $consumeQuantity, 
+        [
+            'include_frozen' => true,
+            'source_type' => 'test_consume',
+            'source_id' => 67890
+        ]
+    );
+    echo "   消耗成功: {$consumeQuantity}个\n";
+    
+    // 检查当前状态
+    echo "\n4. 检查当前物品状态\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 "\n5. 模拟并发场景测试\n";
+    
+    echo "   开始解冻操作(会锁定可用物品)...\n";
+
+    // 这个操作会锁定可用物品记录
+    $unfreezeResult = ItemService::unfreezeItem($freezeLogId);
+
+    echo "   解冻成功: " . json_encode($unfreezeResult, JSON_UNESCAPED_UNICODE) . "\n";
+    echo "   补足差额: " . ($unfreezeResult['shortage_compensated'] ?? 0) . "\n";
+
+    // 检查解冻后的状态
+    echo "\n6. 检查解冻后的物品状态\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 "\n7. 测试边界情况:可用数量不足\n";
+    
+    // 消耗大部分可用物品,使其不足以补足
+    $availableQuantity = DB::table('item_users')
+        ->where('user_id', $userId)
+        ->where('item_id', $itemId)
+        ->where('is_frozen', false)
+        ->sum('quantity');
+    
+    echo "   当前可用数量: {$availableQuantity}\n";
+    
+    if ($availableQuantity > 10) {
+        $consumeMore = $availableQuantity - 5; // 留5个,不足以补足
+        echo "   消耗更多可用物品: {$consumeMore}个\n";
+        
+        $consumeResult2 = ItemService::consumeItem(
+            $userId, 
+            $itemId, 
+            null,
+            $consumeMore, 
+            [
+                'include_frozen' => false, // 只消耗可用物品
+                'source_type' => 'test_consume_more',
+                'source_id' => 67891
+            ]
+        );
+        
+        // 重新冻结更多数量,然后部分消耗,创建需要补足但数量不足的场景
+        $freezeResult2 = ItemService::freezeItem(
+            $userId,
+            $itemId,
+            null,
+            5, // 冻结5个
+            FREEZE_REASON_TYPE::TRADE_ORDER->value,
+            [
+                'source_id' => 12346,
+                'source_type' => 'test_order_2',
+                'operator_id' => 1
+            ]
+        );
+        $freezeLogId2 = $freezeResult2['frozen_items'][0]['freeze_log_id'];
+
+        // 部分消耗这个冻结堆(消耗3个,剩余2个)
+        $consumeResult3 = ItemService::consumeItem(
+            $userId,
+            $itemId,
+            null,
+            3,
+            [
+                'include_frozen' => true,
+                'source_type' => 'test_consume_partial',
+                'source_id' => 67892
+            ]
+        );
+
+        echo "   冻结5个,消耗3个,剩余2个,需要补足3个,但可用数量不足\n";
+        
+        // 尝试解冻(应该失败,因为可用数量不足)
+        echo "   尝试解冻(预期失败)...\n";
+        try {
+            $unfreezeResult2 = ItemService::unfreezeItem($freezeLogId2);
+            echo "   意外成功: " . json_encode($unfreezeResult2, JSON_UNESCAPED_UNICODE) . "\n";
+        } catch (Exception $e) {
+            echo "   预期失败: " . $e->getMessage() . "\n";
+        }
+        
+        // 测试安全解冻
+        echo "   尝试安全解冻...\n";
+        try {
+            $safeUnfreezeResult = ItemService::safeUnfreezeItem($freezeLogId2);
+            echo "   安全解冻结果: " . json_encode($safeUnfreezeResult, JSON_UNESCAPED_UNICODE) . "\n";
+        } catch (Exception $e) {
+            echo "   安全解冻失败: " . $e->getMessage() . "\n";
+        }
+    }
+    
+    echo "\n=== 测试完成 ===\n";
+    
+    DB::rollback();
+    echo "已回滚测试数据\n";
+    
+} catch (Exception $e) {
+    DB::rollback();
+    echo "测试失败: " . $e->getMessage() . "\n";
+    echo "堆栈跟踪: " . $e->getTraceAsString() . "\n";
+}