Bốn vũ khí xử lý tranh chấp tài nguyên phân tán
Chuyển từ Monolith sang Microservices giải quyết được bài toán scale, nhưng lại kéo theo cơn đau đầu về race condition. Thời còn chạy Monolith, một từ khóa synchronized trong Java hay mutex trong Go là đủ khóa gọn tài nguyên trong bộ nhớ RAM. Giờ đây, khi 50 Pod Kubernetes chạy dàn trải trên 10 server vật lý, bộ nhớ cục bộ hoàn toàn chào thua.
Thử tưởng tượng kịch bản Flash Sale: 100 chiếc iPhone 16 Pro Max nhưng có 5.000 request ùa vào cùng một giây. Nếu không khóa tài nguyên chuẩn xác, kho hàng sẽ âm ngay lập tức. Để chặn đứng thảm họa này, chúng ta có 4 lựa chọn:
- Database Locking (RDBMS): Dùng
SELECT ... FOR UPDATE(Pessimistic) hoặc kiểm tra cộtversion(Optimistic). - Redis Single Instance: Tận dụng tính đơn luồng và lệnh nguyên tử:
SET resource_name my_random_value NX PX 30000. - Thuật toán Redlock (Multi-master): Phối hợp xin lock trên N node Redis độc lập (thường là 5 node) theo cơ chế đồng thuận đa số (Quorum).
- Consensus Engine (Zookeeper / Etcd): Dựa vào cơ chế heartbeat, lease và thuật toán Paxos hoặc Raft để đảm bảo tính nhất quán mạnh (CP trong CAP theorem).
Cân đo ưu nhược điểm từng giải pháp
Mỗi công cụ sinh ra để giải quyết một ngưỡng tải và mức độ rủi ro nhất định. Đừng dùng dao mổ trâu để giết gà:
1. Database Locking
- Ưu điểm: Quen thuộc, tận dụng hạ tầng có sẵn, đảm bảo chuẩn ACID tuyệt đối.
- Nhược điểm: Tạo nút thắt cổ chai nặng nề ở tầng DB. Giữ transaction lâu sẽ nuốt cạn connection pool (thường chỉ 100-200 connection). Hệ quả là cả hệ thống bị treo dây chuyền (Cascading Failure).
2. Redis đơn node (hoặc Master – Replica)
- Ưu điểm: Tốc độ bàn thờ nhờ chạy trên RAM với latency dưới 1ms. Cấu hình cực kỳ đơn giản.
- Nhược điểm: Redis replication là bất đồng bộ (asynchronous). Nếu Master nhận lệnh lock rồi lăn ra chết trước khi kịp đồng bộ sang Replica, node Replica được đôn lên sẽ không hề biết có lock đó. Ngay lập tức, 2 client cùng giữ một lock tại một thời điểm.
3. Thuật toán Redlock
- Ưu điểm: Xóa bỏ hoàn toàn điểm nghẽn Single Point of Failure (SPOF). Chỉ cần quá bán số node (3/5 node) gật đầu là lock hợp lệ, kể cả khi 1-2 node chết bất đắc kỳ tử.
- Nhược điểm: Phải quản lý nhiều cụm độc lập. Latency tăng nhẹ do gọi mạng nhiều lần và phụ thuộc nhiều vào độ lệch đồng hồ (clock drift).
4. Zookeeper / Etcd
- Ưu điểm: Cực kỳ an toàn nhờ cơ chế heartbeat và ephemeral znode. Client sập mạng là hệ thống tự nhả lock ngay.
- Nhược điểm: Vận hành phức tạp, ngốn tài nguyên. Throughput chỉ dao động vài nghìn ops/s, thua xa mốc 50.000–100.000 ops/s của Redis.
Chọn giải pháp nào cho bài toán của bạn?
Sau nhiều đợt On-call toát mồ hôi hột, mình đúc kết ra công thức lựa chọn ngắn gọn sau:
- Dùng Redis đơn node (kèm Lua Script): Phù hợp khi cần throughput khủng (vài chục nghìn req/s). Lỗi tranh chấp nếu có xảy ra (xác suất dưới 0.1%) không gây cháy nhà và hoàn toàn bù đắp được bằng batch job đối soát cuối ngày.
- Dùng Redlock hoặc Etcd: Bắt buộc áp dụng cho luồng thanh toán, trừ số dư ví, hoặc các Cronjob định kỳ chỉ được phép nổ đúng 1 lần duy nhất trên toàn cụm.
Mẹo nhỏ cho anh em khi dựng mock data để stress-test cụm Redis: nếu cần convert nhanh file CSV danh sách user/order sang JSON, bạn ghé toolcraft.app/vi/tools/data/csv-to-json dùng thử. Tool xử lý trực tiếp trên browser của máy bạn nên không sợ leak dữ liệu nhạy cảm của khách hàng.
Bắt tay vào code Distributed Lock
Bước 1: Chuẩn hóa trên Single Node với Lua Script
Nhiều bạn mới vào nghề thường gọi SETNX rồi mới gọi EXPIRE. Cách này rất tai hại. Nếu process bị kill đúng lúc nằm giữa 2 lệnh, key sẽ sống vĩnh viễn và tạo ra deadlock cứng. Hãy luôn dùng lệnh SET kèm cờ nguyên tử:
import uuid
import time
import redis
client = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
def acquire_lock(lock_name, acquire_timeout=10, lock_timeout=10):
"""
lock_timeout: Thời gian giữ lock tối đa trước khi tự hủy (giây)
acquire_timeout: Thời gian kiên nhẫn thử lấy lock (giây)
"""
identifier = str(uuid.uuid4()) # Tránh việc giải phóng nhầm lock của process khác
lock_key = f"lock:{lock_name}"
end_time = time.time() + acquire_timeout
while time.time() < end_time:
# nx=True: chỉ set nếu chưa có; ex=lock_timeout: tự hủy sau X giây
if client.set(lock_key, identifier, nx=True, ex=lock_timeout):
return identifier
time.sleep(0.05) # Tránh vòng lặp quá gắt gây nghẽn CPU
return False
Khi nhả lock, cấm kỵ dùng lệnh DEL trực tiếp. Nếu task chạy lố thời gian (quá 10s), lock cũ đã tự biến mất và client khác đã kịp chiếm lock mới. Một lệnh DEL ngây thơ sẽ xóa nhầm lock của người ta. Hãy kiểm tra đúng chủ sở hữu bằng Lua script nguyên tử:
RELEASE_LOCK_LUA = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
"""
def release_lock(lock_name, identifier):
lock_key = f"lock:{lock_name}"
result = client.eval(RELEASE_LOCK_LUA, 1, lock_key, identifier)
return result == 1
Bước 2: Nâng cấp lên Redlock đa node
Muốn hệ thống chịu tải an toàn ở mức cao hơn, hãy dựng 5 node Redis độc lập (không master-replica, không cluster sharding). Thuật toán Redlock vận hành tuần tự qua 4 nhịp:
- Ghi lại mốc thời gian bắt đầu (T1).
- Gửi request xin lock lần lượt tới 5 node với timeout rất ngắn (5–50ms). Mục đích là để không bị kẹt lại ở các node đang chết lâm sàng.
- Tính thời gian đã tiêu tốn:
elapsed_time = now() - T1. Lock thành công khi: xin được ít nhất 3/5 node VÀ thời gian còn hiệu lựcValidity Time = TTL - elapsed_time - clock_driftphải lớn hơn 0. - Nếu không gom đủ 3 node hoặc hết hạn, lập tức gửi lệnh mở khóa trên toàn bộ 5 node.
Cài đặt thư viện Python:
pip install redlock-py
Đoạn code áp dụng thực tế:
from redlock import Redlock
import time
# Kết nối 5 node Redis độc lập trên 5 máy chủ riêng biệt
dlm = Redlock([
{"host": "10.0.0.1", "port": 6379, "socket_timeout": 0.1},
{"host": "10.0.0.2", "port": 6379, "socket_timeout": 0.1},
{"host": "10.0.0.3", "port": 6379, "socket_timeout": 0.1},
{"host": "10.0.0.4", "port": 6379, "socket_timeout": 0.1},
{"host": "10.0.0.5", "port": 6379, "socket_timeout": 0.1},
], retry_count=3, retry_delay=0.2)
resource_key = "order:payment:20261103"
# Giữ lock trong 10,000ms (10 giây)
my_lock = dlm.lock(resource_key, 10000)
if my_lock:
try:
print("Đã chiếm lock. Đang trừ tiền số dư ví...")
time.sleep(2) # Xử lý logic thanh toán quan trọng
finally:
dlm.unlock(my_lock)
print("Đã nhả lock an toàn.")
else:
print("Tài nguyên đang bận, vui lòng thử lại sau!")
3 bài học sống còn khi vận hành Production
- Cơ chế Watchdog (Tự gia hạn TTL): Một đợt Stop-the-world của Java GC hay câu query chậm có thể khiến tác vụ chạy vượt quá 10 giây. Khi đó lock tự nhả dù logic chưa xong. Hãy tạo một background thread (tương tự Watchdog trong Redisson) để gia hạn TTL định kỳ chừng nào tiến trình vẫn còn sống.
- Coi chừng Clock Drift: Redlock dựa vào thời gian thực để tính TTL. Đồng hồ server chỉ cần lệch 500ms là thuật toán có thể phán đoán sai. Bạn bắt buộc phải bật đồng bộ NTP trên toàn bộ máy chủ.
- Fencing Token: Với nghiệp vụ tài chính khắt khe, Distributed Lock thuần túy vẫn chưa đủ. Server cấp lock cần sinh kèm một con số tự tăng (token: 1, 2, 3…). Khi ghi xuống Storage, Database sẽ từ chối mọi transaction mang token nhỏ hơn hoặc bằng token đã ghi nhận trước đó.
