Tình huống e-commerce: làm thế nào để đảm bảo chỉ một người mua được sản phẩm cuối cùng

Công nghệ - 15/04/2025 09:43:56

🧠 Nếu cả Nam và Hằng cùng bấm "Mua" một lúc khi chỉ còn 1 sản phẩm, ai sẽ được? Đây là bài toán kinh điển về tranh chấp trong thương mại điện tử. Bài viết hé lộ cách giải thông minh bằng SemaphoreSlim trong C# mà không cần SQL lock!

Bạn hãy tưởng tượng một website bán hàng đang có một chiếc PS5 phiên bản giới hạn, đang giảm giá $100 nhưng chỉ còn một chiếc trong kho. Hai khách hàng, Nam và Hằng, cùng nhìn thấy thấy InStock=1, đồng thời nhấn nút "Mua" - Place Order. Sẽ xảy ra tình huống gì ở đây?

Trong hệ thống thương mại điện tử, việc xử lý hai khách hàng cùng nhấn nút "Mua" cho sản phẩm cuối cùng là một bài toán concurrency điển hình. Nếu không kiểm soát tốt, cả hai có thể nhận thông báo "Mua thành công", dẫn đến bán quá mức (overselling) và lỗi dữ liệu.

Trong bài viết này, tôi sẽ trình bày một giải pháp sử dụng pessimistic locking với SemaphoreSlim trong C#, đồng thời tái cấu trúc code để tuân thủ nguyên tắc SOLID bằng cách tách logic quản lý kho sang một InventoryService. Tôi cũng sẽ cung cấp các unit test để đảm bảo tính đúng đắn của giải pháp.

Trong bài viết này, tôi sẽ trình bày một giải pháp sử dụng pessimistic locking với SemaphoreSlim trong C#, đồng thời cấu trúc code để tuân thủ nguyên tắc SOLID bằng cách tách logic quản lý kho sang một InventoryService - chúng ta chưa quan tâm tới service này vội nhé.


Phương án thực hiện

1. Khóa giao dịch với SemaphoreSlim: Đảm bảo chỉ một giao dịch được xử lý cho mỗi sản phẩm tại một thời điểm.

2. Tách logic kho sang InventoryService:

  • InventoryService chịu trách nhiệm lấy sản phẩm và kiểm tra/cập nhật số lượng hàng.
  • PurchaseService chỉ quản lý giao dịch mua và khóa.

3. Sử dụng Dependency Injection: Đảm bảo các service có thể thay thế nhau, tuân thủ Dependency Inversion Principle (DIP).

Giả sử chúng ta có 1 class Product đơn giản chỉ gồm: Id, Name và Stock

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int Stock { get; set; }
}

Đồng thời có interface IInventoryService để chứa các nghiệp vụ của inventory.

public interface IInventoryService
{
    Task<Product?> GetProductAsync(int productId);
    Task<bool> UpdateStockAsync(int productId, int quantityToReduce);
}

1 interface IPurchaseService và implementation của nó được xây dựng dưới đây

public interface IPurchaseService
{
    Task<bool> TryPurchaseAsync(int productId);
}
public class PurchaseService : IPurchaseService
{
    private readonly IInventoryService _inventoryService;
    private static readonly ConcurrentDictionary<int, SemaphoreSlim> _productLocks = new();

    public PurchaseService(IInventoryService inventoryService)
    {
        _inventoryService = inventoryService;
    }

    public async Task<bool> TryPurchaseAsync(int productId)
    {
        var semaphore = _productLocks.GetOrAdd(productId, _ => new SemaphoreSlim(1, 1));

        try
        {
            if (!await semaphore.WaitAsync(TimeSpan.FromSeconds(5)))
            {
                return false; // Timeout
            }

            return await _inventoryService.UpdateStockAsync(productId, 1);
        }
        catch (Exception ex)
        {
            // Log lỗi (giả sử có logger)
            Console.WriteLine($"Purchase error: {ex.Message}");
            return false;
        }
        finally
        {
            semaphore.Release();
            if (semaphore.CurrentCount == 1)
            {
                _productLocks.TryRemove(productId, out _);
            }
        }
    }
}

 


Biên dịch đoạn code (trong đầu nhé)

Tình huống

  • Giả sử Nam và Hằng cùng nhấn "Mua" cho chiếc PS5 (ID=1, Stock=1)
  • Yêu cầu của Nam đến trước, lấy được khóa (semaphore.WaitAsync).
  • PurchaseService gọi InventoryService.UpdateStockAsync, kiểm tra Stock=1, giảm xuống 0, lưu thay đổi, trả về true.
  • Yêu cầu của Hằng phải đợi cho đến khi khóa được giải phóng. Khi được xử lý, UpdateStockAsync thấy Stock=0, trả về false.

Kết quả: Nam mua thành công, Hằng nhận thông báo hết hàng => Hằng gọi điện cho Nam, 2 bạn cùng về một nhà chơi game cùng nhau :)


Tại sao sử dụng SemaphoreSlim?

  • Hỗ trợ bất đồng bộ: SemaphoreSlim cho phép sử dụng await trong các ứng dụng web, tránh deadlock mà lock có thể gây ra trong môi trường async.
  • Khóa theo sản phẩm: Thay vì khóa toàn bộ hệ thống, chỉ khóa sản phẩm cụ thể (productId), cải thiện hiệu suất.
  • An toàn đa luồng: ConcurrentDictionary đảm bảo việc thêm/xóa semaphore không gây xung đột.

Cách đảm bảo tính toàn vẹn dữ liệu

  • Khóa độc quyền: SemaphoreSlim đảm bảo chỉ một giao dịch được xử lý, loại bỏ khả năng hai giao dịch cùng đọc Stock > 0.
  • Giao dịch ngắn gọn: Việc kiểm tra và cập nhật Stock xảy ra trong một DbContext riêng, giảm thiểu thời gian khóa.
  • Xử lý lỗi: Khối catch đảm bảo nếu có lỗi (ví dụ, lỗi cơ sở dữ liệu), giao dịch sẽ thất bại và khóa được giải phóng.

Để chắc chắn, chúng ta cùng viết nhanh một cái UnitTest cho trường hợp này, vì Test cực kỳ quan trọng mà :)

using Microsoft.EntityFrameworkCore;
using Moq;
using System;
using System.Threading.Tasks;
using Xunit;

public class PurchaseServiceConcurrencyTests
{
    private readonly Mock<IInventoryService> _inventoryServiceMock;
    private readonly IPurchaseService _purchaseService;

    public PurchaseServiceConcurrencyTests()
    {
        _inventoryServiceMock = new Mock<IInventoryService>();
        _purchaseService = new PurchaseService(_inventoryServiceMock.Object);
    }

    [Fact]
    public async Task TryPurchaseAsync_TwoUsersConcurrent_OnlyOneSucceeds()
    {
        // Arrange
        int productId = 1;
        bool firstPurchaseResult = false;
        bool secondPurchaseResult = false;

        // Giả lập InventoryService: chỉ cho phép một lần UpdateStockAsync thành công
        _inventoryServiceMock
            .SetupSequence(s => s.UpdateStockAsync(productId, 1))
            .ReturnsAsync(true)  // Lần đầu thành công
            .ReturnsAsync(false); // Lần hai thất bại (hết hàng)

        // Act
        // Chạy hai giao dịch đồng thời
        var task1 = Task.Run(async () => firstPurchaseResult = await _purchaseService.TryPurchaseAsync(productId));
        var task2 = Task.Run(async () => secondPurchaseResult = await _purchaseService.TryPurchaseAsync(productId));

        // Đợi cả hai hoàn thành
        await Task.WhenAll(task1, task2);

        // Assert
        // Chỉ một trong hai giao dịch thành công
        Assert.True(firstPurchaseResult != secondPurchaseResult, "Exactly one purchase should succeed");
        Assert.Equal(2, _inventoryServiceMock.Invocations.Count); // UpdateStockAsync được gọi đúng 2 lần
    }
}

Kịch bản giả lập:

Tình huống: Hai người dùng (giả lập bằng hai task) gọi TryPurchaseAsync cho sản phẩm ID=1 gần như đồng thời. Sản phẩm chỉ có 1 trong kho.

Mong đợi:

  • SemaphoreSlim đảm bảo chỉ một giao dịch được xử lý tại một thời điểm.
  • Giao dịch đầu tiên thành công (true), giảm Stock xuống 0.
  • Giao dịch thứ hai thất bại (false) vì Stock đã hết.

Cách SemaphoreSlim đảm bảo concurrency

  • Trong PurchaseService, SemaphoreSlim khóa theo productId. Khi task1 lấy được khóa, task2 phải đợi.
  • Sau khi task1 gọi UpdateStockAsync (thành công) và giải phóng khóa, task2 mới được xử lý, nhưng lúc này UpdateStockAsync trả về false vì kho đã hết.

Ưu và nhược điểm của phương án này

Phương án hoạt động được, nhưng phương án nào cũng có ưu và nhược, chúng ta thử phân tích nhé

Ưu điểm

  1. Đáp ứng yêu cầu: Sử dụng khóa trong C# thay vì SQL, phù hợp với yêu cầu không phụ thuộc vào cơ chế khóa của cơ sở dữ liệu.
  2. Tính toàn vẹn dữ liệu: Đảm bảo chỉ một người dùng mua được sản phẩm cuối cùng, tránh overselling.
  3. Hiệu quả cho sản phẩm hot: Khóa theo productId giảm thiểu tác động đến các sản phẩm khác, phù hợp với kịch bản sản phẩm giới hạn số lượng.
  4. Xử lý bất đồng bộ: SemaphoreSlim và EF Core hoạt động tốt trong môi trường async, đảm bảo hiệu suất cho ứng dụng web.
  5. Dễ mở rộng: Có thể thêm logic như kiểm tra số lượng lớn hơn 1, gửi thông báo, hoặc ghi log mà không cần thay đổi cơ chế khóa.

Nhược điểm

  1. Tác động hiệu suất: Nếu có hàng nghìn người dùng cùng tranh giành một sản phẩm, các giao dịch phải đợi lần lượt (WaitAsync), có thể gây chậm trễ.
  2. Timeout rủi ro: Nếu thời gian chờ (5 giây) quá ngắn trong trường hợp tải cao, một số giao dịch có thể bị từ chối không cần thiết.
  3. Phụ thuộc vào bộ nhớ: ConcurrentDictionary lưu semaphore trong RAM, có thể gây vấn đề nếu hệ thống chạy lâu dài với nhiều sản phẩm.
  4. Không phân tán: SemaphoreSlim chỉ hoạt động trong một instance ứng dụng. Nếu hệ thống chạy trên nhiều server, cần cơ chế khóa phân tán như Redis.
  5. Khả năng lỗi không lường trước: Nếu server crash trong lúc đang giữ khóa, semaphore không được giải phóng, có thể gây tắc nghẽn tạm thời (mặc dù timeout giúp giảm thiểu vấn đề này).

Một số phương án khắc phục nhược điểm

Tối ưu hiệu suất: Sử dụng hàng đợi (queue) với RabbitMQ hoặc Azure Service Bus để xếp hàng các giao dịch thay vì khóa trực tiếp. Điều này cho phép xử lý thứ tự mà không cần giữ khóa lâu. Thay vì SemaphoreSlim, gửi yêu cầu mua vào một queue và xử lý từng yêu cầu một cách tuần tự.

Tăng thời gian timeout hoặc retry: Cho phép retry giao dịch nếu timeout xảy ra, hoặc điều chỉnh timeout dựa trên tải hệ thống (ví dụ, 10 giây thay vì 5 giây) - có thể sử dụng thư viện Polly cho phương án này.

Quản lý bộ nhớ: Thêm cơ chế tự động xóa semaphore không sử dụng sau một khoảng thời gian (ví dụ, 1 giờ). Sử dụng Timer hoặc một background service để kiểm tra và xóa các semaphore không hoạt động.

Hỗ trợ hệ thống phân tán: hay SemaphoreSlim bằng khóa phân tán với Redis hoặc ZooKeeper để hỗ trợ nhiều server.

 


Kết luận

Sử dụng khóa SemaphoreSlim trong C# là một phương án xử lý đảm bảo chỉ một người dùng mua được sản phẩm cuối cùng trong tình huống hai client tranh chấp, nhấn "Mua" cùng lúc. Bằng cách khóa theo sản phẩm, chúng ta loại bỏ nguy cơ overselling và giữ được tính toàn vẹn dữ liệu.

Giải pháp này đặc biệt phù hợp cho các hệ thống thương mại điện tử vừa và nhỏ, nơi các sản phẩm hot thường xuyên gây ra tranh giành. Dù có một số nhược điểm về hiệu suất và hỗ trợ phân tán, chúng có thể được khắc phục bằng các công cụ như Redis, queue, hoặc retry mechanism.

Tôi tin rằng cách tiếp cận này cân bằng giữa độ tin cậy, tính đơn giản, và khả năng mở rộng.

Nếu bạn có những phương án khác hay bạn đang xây dựng một hệ thống tương tự, hãy chia sẻ phương án và cùng trao đổi nhé!

Happy coding.

#DotNet #CSharp #SemaphoreSlim #Concurrency #EcommerceDev #CleanCode #AsyncProgramming DevBlog #CodeSharing #TechLeadership #SystemDesign #SoftwareArchitecture #BackendDevelopment #DeveloperLife

#wecommit100xshare #1percentbetter

 

/Son Do - ngoài search cũng thích e-commerce :)

Công nghệ - 19/08/2025 21:13:07

Tìm hiểu cách xây dựng hệ thống phát hiện ngôn ngữ ký hiệu theo thời gian thực bằng AI, sử dụng DETR để tăng cường khả năng tiếp cận và đổi mới. Kết nối lời nói và cử chỉ.

Công nghệ - 18/08/2025 13:38:25

Tối ưu hóa các hệ thống RAG bằng cách tận dụng siêu dữ liệu để truy xuất thông tin chính xác và nhanh chóng hơn, giải quyết các thách thức về dữ liệu dư thừa hoặc lỗi thời với công cụ LangExtract nguồn mở. Khám phá cách LangExtract sử dụng các mô hình ngôn ngữ tiên tiến để trích xuất và cấu trúc siêu dữ liệu, tạo ra một quy trình truy xuất hợp lý và hiệu quả.

Công nghệ - 01/08/2025 07:00:00

Gỡ lỗi LLM rất quan trọng vì quy trình làm việc của chúng phức tạp và liên quan đến nhiều phần như chuỗi, lời nhắc, API, công cụ, trình truy xuất, v.v.

Công nghệ - 19/06/2025 03:05:09

Code xong chạy được là chưa đủ – phải biết khi nào nó "chết" nữa chứ 😅

Bạn đang triển khai ứng dụng trên Kubernetes, Docker hay môi trường production nào? Và bạn từng "toát mồ hôi" vì service chết mà không ai báo?

Công nghệ - 16/07/2025 13:41:17

Bạn có bao giờ tự hỏi tại sao trang web của mình tải chậm, đặc biệt là trên các thiết bị di động? Rất có thể, thủ phạm chính là những hình ảnh chưa được tối ưu. May mắn thay, có một công cụ miễn phí và cực kỳ hữu ích có thể giúp bạn giải quyết vấn đề này: Responsive Image Linter – một tiện ích mở rộng trên Chrome. Video này sẽ giới thiệu chi tiết về công cụ này, giúp bạn xác định và tối ưu hóa các hình ảnh gây tốn hiệu năng trên trang web của mình.

Công nghệ - 27/06/2025 03:15:44

⏳ Chậm 3 giây – Mất 50% người dùng. Đó không còn là lý thuyết, đó là thực tế.

Công nghệ - 11/12/2025 15:05:29

[Góc chuyện nghề] bán account game để đi học nghệ - bạn dám không?

Làm nghề 20 năm, gặp nhiều sinh viên, nhưng chiều qua tôi khá bất ngờ với một cậu em tên Quang. Em Quang muốn theo nghề BA và mong muốn lương 20 triệu sau khi làm việc 1.5 năm tới 2 năm trong nghề.

Công nghệ - 22/09/2025 08:59:20

Dừng ngay việc dùng DateTime.Now trong APIs, đó là ổ lỗi tiềm ẩn trong hệ thống của bạn

⏱️ Tôi từng nghĩ DateTime.Now là một thứ vô hại, đơn giản và tiện lợi, cho đến khi gặp những vấn đề về múi giờ. Những lỗi "tưởng chừng nhỏ" này lại chính là nguồn cơn của sự thất vọng và tốn kém thời gian cho nhiều đội ngũ phát triển.

Công nghệ - 14/03/2025 04:30:32

💡Bạn muốn tăng tốc tìm kiếm toàn văn nhưng hạ tầng hạn chế? Lucene có thể là giải pháp bất ngờ! Bài viết tiết lộ cách nó vượt trội hơn SQL Server, tối ưu truy vấn và những ứng dụng thực tế đáng khám phá.