+2

Series Redis Thực chiến | Bài 2: Distributed Lock - "Tuyệt kỹ" chống kẹt giao dịch và Double-Click

Chào anh em! Chắc hẳn anh em làm backend đều từng gặp trường hợp: Khách hàng mua một cái vé, do mạng lag nên họ bấm nút "Thanh toán" liên tục 2-3 lần.

Kết quả là 3 request bay lên server gần như cùng một phần nghìn giây. Nếu code của anh em viết theo kiểu:

  1. Kiểm tra số dư (DB trả về: Còn 100k)
  2. Trừ tiền vé 50k (Tính toán: 100k - 50k = 50k)
  3. Cập nhật lại số dư vào DB (Lưu 50k)

Vì 3 request chạy song song (Race Condition), cả 3 đều đọc được số dư là 100k và cùng cập nhật lại là 50k. Khách hàng mua 3 vé nhưng hệ thống chỉ trừ tiền 1 vé! Thảm họa tài chính bắt đầu từ đây.

Để ngăn chặn điều này, chúng ta cần một cái "Ổ khóa" (Lock). Khi Request 1 đang xử lý, Request 2 và 3 phải bị khóa lại ở ngoài. Và Redis Distributed Lock chính là ổ khóa vững chắc nhất trong môi trường phân tán.

1. Bản chất của Redis Lock: Lệnh SET NX

Nhiều anh em dùng Database để lock (như SELECT ... FOR UPDATE), nhưng cách này làm database bị nghẽn (deadlock) cực kỳ nguy hiểm. Redis giải quyết việc này trên RAM với tốc độ ánh sáng nhờ tham số thần thánh: NX (Not eXists).

Câu lệnh nguyên thủy của Redis Lock trông như sau:

SET lock:ticket_123 "random_string_token" NX EX 10

Giải ngố câu lệnh:

  • lock:ticket_123: Tên của ổ khóa (Key).
  • "random_string_token": Chìa khóa bí mật của người đang giữ lock (sẽ giải thích ở phần sau).
  • NX: Chỉ tạo ra Key này NẾU NÓ CHƯA TỒN TẠI. (Đây là phép thử Atomic - không thể có 2 request cùng set thành công).
  • EX 10: Ổ khóa sẽ tự động biến mất sau 10 giây (Tránh việc server chết giữa chừng làm ổ khóa bị kẹt vĩnh viễn).

2. Code thực chiến: Áp dụng Distributed Lock trong Laravel

May mắn thay, nếu anh em dùng Laravel, framework này đã bọc sẵn các lệnh phức tạp của Redis thành một Facade cực kỳ thanh lịch.

Hãy xem cách chúng ta refactor lại hàm Thanh toán để chống Double-Click:

<?php

namespace App\Http\Controllers;

use Illuminate\Support\Facades\Cache;
use Illuminate\Http\Request;

class TicketController extends Controller
{
    public function purchase(Request $request)
    {
        $userId = $request->user()->id;
        $ticketId = $request->ticket_id;
        
        // 1. Tạo tên ổ khóa duy nhất cho User và Ticket này
        $lockKey = "lock:purchase_ticket:{$userId}:{$ticketId}";

        // 2. Cố gắng lấy ổ khóa trong 10 giây
        $lock = Cache::lock($lockKey, 10);

        // Hàm get() sẽ trả về TRUE nếu lấy được khóa, FALSE nếu có người khác đang giữ
        if ($lock->get()) {
            try {
                // ĐÃ LẤY ĐƯỢC KHÓA -> Bắt đầu xử lý nghiệp vụ an toàn
                
                // Kiểm tra số dư...
                // Trừ tiền...
                // In vé...
                
                return response()->json(['message' => 'Mua vé thành công!']);

            } finally {
                // QUAN TRỌNG: Xử lý xong phải trả lại ổ khóa ngay lập tức
                $lock->release();
            }
        }

        // KHÔNG LẤY ĐƯỢC KHÓA -> Request thứ 2, 3 sẽ rơi vào đây
        return response()->json([
            'error' => 'Hệ thống đang xử lý giao dịch của bạn, vui lòng không thao tác lại!'
        ], 429);
    }
}

3. Lỗ hổng chết người và "Ma thuật" Lua Script

Mọi thứ có vẻ hoàn hảo, nhưng hãy nhìn sâu hơn vào một kịch bản "Hiểm nghèo":

  1. Request A lấy được khóa (hạn 10s).
  2. Chẳng may Request A gọi API sang cổng thanh toán bị lag, mất tới 15s mới xong.
  3. Ở giây thứ 10, Redis tự động xóa khóa của Request A (vì hết hạn EX 10).
  4. Ngay lúc đó, Request B bay vào và lấy được khóa (vì khóa cũ đã mất).
  5. Ở giây thứ 15, Request A xử lý xong, chạy lệnh $lock->release(). Do khóa của nó đã mất, nó sẽ XÓA LUÔN KHÓA CỦA REQUEST B đang giữ!

Hệ thống sụp đổ vì ổ khóa bị mở sai người!

Cách giải quyết: Khi tạo khóa, Redis phải lưu một cái "random_string_token" (như ở mục 1). Khi gọi lệnh xóa khóa (release), hệ thống phải kiểm tra: "Mày có đúng là thằng đã tạo ra khóa này không? Đúng token tao mới cho xóa!".

Để làm 2 việc "Kiểm tra" và "Xóa" cùng lúc mà không bị xen ngang, các kỹ sư hệ thống phải dùng một đoạn mã Lua Script chạy trực tiếp trên lõi của Redis:

-- Mã Lua Script nguyên thủy bên dưới Laravel Cache::lock()->release()
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

Tin vui là hàm $lock->release() của Laravel đã ngầm implement đoạn mã Lua này cho anh em rồi. Nhưng là một kỹ sư hệ thống giỏi, anh em bắt buộc phải hiểu cơ chế bên dưới lớp vỏ bọc hào nhoáng đó để tự tin debug khi có sự cố.

Tổng kết Bài 2

Với Distributed Lock của Redis, anh em đã có trong tay chiếc khiên vững chắc nhất để bảo vệ tính toàn vẹn dữ liệu (Data Integrity). Không còn sợ Double-Click, không còn sợ kẹt giao dịch hay Race Condition ở những hệ thống phân tán.

Ở Bài 3, chúng ta sẽ giải quyết một bài toán đau đầu không kém: Rate Limiting – Làm sao để chặn đứng hàng triệu request rác (DDoS) hoặc spam API từ một IP mà không làm chậm hệ thống? Hẹn anh em ở bài sau!

Sự chặt chẽ của Distributed Lock rất quan trọng khi xử lý giao dịch tài chính. Bạn đã sẵn sàng để tiếp tục với Bài 3 về Rate Limiting (Giới hạn truy cập), hay muốn bổ sung thêm cơ chế tự động thử lại (Retry/Block) cho cái Lock ở bài này không?


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí