Chuyển tới nội dung chính

19.8 — 7. Distributed lock

Tóm tắt

Đây là bài quan trọng nhất về mặt an toàn trong module, và thông điệp chính đi ngược với trực giác: khoá phân tán không bao giờ an toàn tuyệt đối. Lý do không phải cài đặt kém mà là một giới hạn cơ bản — bạn không thể phân biệt "tiến trình đã chết" với "tiến trình còn sống nhưng bị đứng vì GC pause hoặc mạng chậm". Kết quả: process A đang giữ khoá, bị GC pause 10 giây, khoá hết hạn, process B lấy được khoá, rồi A tỉnh dậy và tin rằng nó vẫn đang giữ khoá. Hai process cùng chạy đoạn "được bảo vệ". Cách đúng có hai lớp: dùng khoá để giảm tranh chấp, và dựa vào idempotency hoặc unique constraint của database cho tính đúng đắn. Và tốt hơn cả: nhiều bài toán không cần khoá — phân vùng theo key hoặc SKIP LOCKED giải quyết triệt để hơn.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Giải thích vì sao khoá phân tán không an toàn tuyệt đối.
  • Cài khoá Redis đúng cách với token sở hữu.
  • Dùng sp_getapplock khi đã có SQL Server.
  • Áp dụng fencing token khi cần tính đúng đắn thật.
  • Chọn phương án không cần khoá khi có thể.

Nội dung bài học​

19.8.1 — Vì sao không an toàn tuyệt đối​

t=0   Process A lay khoa, TTL 30 giay
t=5 A bắt đầu xử lý
t=10 A bị GC pause / mạng chậm / VM bị dừng tạm
t=35 Khoá HẾT HẠN (A không biết — nó đang bị dừng)
t=36 Process B lấy được khoá, bắt đầu xử lý
t=40 A tỉnh dậy, TIN rằng mình vẫn giữ khoá, tiếp tục xử lý
=> A và B CÙNG chạy đoạn được bảo vệ

Đây không phải bug của Redis hay của thư viện. Đó là hệ quả của một sự thật: không có cách nào phân biệt tiến trình chết với tiến trình chậm trong hệ phân tán. TTL phải tồn tại (nếu không, một tiến trình chết sẽ giữ khoá vĩnh viễn), và TTL tồn tại nghĩa là khoá có thể hết hạn trong lúc chủ sở hữu vẫn đang làm việc.

Martin Kleppmann phân tích chi tiết vấn đề này và đưa ra kết luận thực dụng: phân biệt hai mục đích dùng khoá.

Mục đíchYêu cầuKhoá phân tán có đủ không
Hiệu quả — tránh làm trùng việcLàm trùng thì tốn tài nguyên, không saiĐủ
Tính đúng đắn — không được phép trùngLàm trùng là sai dữ liệu hoặc mất tiềnKhông đủ

Hầu hết trường hợp thực tế thuộc loại đầu: outbox dispatcher chạy trùng chỉ gây publish trùng, mà consumer idempotent đã xử lý (bài 17.3).

19.8.2 — Khoá Redis đúng cách​

public sealed class RedisLock(IConnectionMultiplexer redis)
{
public async Task<IAsyncDisposable?> AcquireAsync(
string key, TimeSpan ttl, CancellationToken ct)
{
var db = redis.GetDatabase();
var token = Guid.NewGuid().ToString(); // định danh CHỦ SỞ HỮU

// SET key token NX EX ttl — nguyen tu
var acquired = await db.StringSetAsync(
$"lock:{key}", token, ttl, when: When.NotExists);

return acquired ? new LockHandle(db, $"lock:{key}", token) : null;
}

private sealed class LockHandle(IDatabase db, string key, string token) : IAsyncDisposable
{
// Lua script: CHỈ xoá nếu token khớp — nguyên tử
private const string ReleaseScript = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
""";

public async ValueTask DisposeAsync() =>
await db.ScriptEvaluateAsync(ReleaseScript, [key], [token]);
}
}

Hai chi tiết quyết định tính đúng đắn của cài đặt này:

Token sở hữu. Không có nó, bạn có thể xoá khoá của người khác:

// SAI — xoá khoá mà KHÔNG kiểm tra chủ sở hữu
await db.KeyDeleteAsync($"lock:{key}");
// Nếu khoá của bạn đã hết hạn và B đã lấy được,
// lệnh này xoá khoá CỦA B -> C cũng vào được -> ba process cùng chạy

Lua script cho việc giải phóng. Kiểm tra rồi xoá bằng hai lệnh riêng có race condition: khoá có thể hết hạn giữa lệnh GET và lệnh DEL. Lua script chạy nguyên tử trên Redis nên không có khoảng đó.

// Su dung
await using var handle = await _lock.AcquireAsync("outbox-dispatcher", TimeSpan.FromMinutes(1), ct);
if (handle is null)
{
_logger.LogDebug("Instance khác đang chạy, bỏ qua lượt này");
return;
}

await DispatchOutboxAsync(ct);

TTL phải dài hơn thời gian xử lý tối đa, nhưng không quá dài — nếu tiến trình chết, mọi người phải chờ hết TTL. Với công việc dài, gia hạn khoá định kỳ trong lúc chạy an toàn hơn là đặt TTL rất lớn.

19.8.3 — sp_getapplock: không cần thêm hạ tầng​

Nếu đã có SQL Server, đây thường là lựa chọn tốt hơn Redis — một hạ tầng ít hơn để vận hành.

public async Task<bool> TryAcquireAsync(string resource, TimeSpan timeout, CancellationToken ct)
{
var result = await _db.Database
.SqlQuery<int>($"""
DECLARE @result INT;
EXEC @result = sp_getapplock
@Resource = {resource},
@LockMode = 'Exclusive',
@LockOwner = 'Transaction',
@LockTimeout = {(int)timeout.TotalMilliseconds};
SELECT @result;
""")
.FirstAsync(ct);

return result >= 0; // 0 = lấy được ngay, 1 = lấy được sau khi chờ
}

Ưu điểm lớn nhất: với @LockOwner = 'Transaction', khoá được giải phóng tự động khi transaction kết thúc — kể cả khi ứng dụng crash. Không có TTL nghĩa là không có tình huống "khoá hết hạn trong lúc đang làm việc".

Redis locksp_getapplock
Hạ tầng thêmCần RedisKhông
Tự giải phóng khi crashQua TTL (có độ trễ)Ngay lập tức
Hiệu năngRất nhanhChậm hơn, phải giữ kết nối
Độc lập databaseCóKhoá vào SQL Server

PostgreSQL có pg_advisory_lock với cơ chế tương tự.

19.8.4 — Fencing token: khi cần tính đúng đắn thật​

Khi làm trùng là sai dữ liệu, khoá một mình không đủ. Thêm một số tăng dần và để tài nguyên đích từ chối token cũ.

// Lấy khoá KÈM một số tăng dần
var fencingToken = await _db.StringIncrementAsync("lock:fence:report-gen");

// Truyền token xuống tài nguyên đích
await _storage.WriteReportAsync(reportId, data, fencingToken);
-- Tài nguyên đích TỪ CHỐI token cũ hơn
UPDATE Reports
SET Data = @data, FencingToken = @token
WHERE Id = @id AND FencingToken < @token; -- process cũ bị CHẶN tại đây

Quay lại kịch bản ở mục 19.8.1: A có token 41, B có token 42. B ghi trước, FencingToken thành 42. A tỉnh dậy ghi với token 41 → điều kiện FencingToken < 41 sai → UPDATE không ảnh hưởng hàng nào. Ghi cũ bị từ chối, dữ liệu đúng.

Điều kiện để dùng được: tài nguyên đích phải hỗ trợ kiểm tra này. Database làm được qua WHERE. Blob storage làm được qua ETag. Một API bên thứ ba thường thì không — và khi đó bạn không có cách nào đảm bảo tính đúng đắn bằng khoá.

19.8.5 — Những lựa chọn tốt hơn khoá​

Trước khi dùng khoá, kiểm tra xem bài toán có thuộc ba loại sau không — cả ba đều loại bỏ nhu cầu khoá thay vì quản lý nó.

1. Phân vùng theo key. Mỗi worker xử lý một tập key cố định, nên không bao giờ có hai worker chạm cùng một bản ghi.

// Worker i chỉ xử lý các bản ghi có hash chia hết cho tổng số worker
var myPartition = GetHash(record.Id) % totalWorkers;
if (myPartition != _workerIndex) continue;

Đây là cách Kafka consumer group hoạt động, và nó triệt để hơn khoá rất nhiều: không có tranh chấp nào để giải quyết.

2. SKIP LOCKED — để database làm việc khoá.

-- Mỗi worker lấy một bộ hàng KHÁC NHAU, không worker nào chờ ai
SELECT * FROM Jobs
WHERE Status = 'Pending'
ORDER BY CreatedAt
LIMIT 10
FOR UPDATE SKIP LOCKED;

Đây chính là cách outbox dispatcher hoạt động mà không cần khoá phân tán (bài 17.4). Database đã có cơ chế khoá đúng đắn — dùng nó thay vì xây thêm một lớp khoá riêng ở trên.

3. Idempotency + unique constraint.

// Chạy trùng không sao — database đảm bảo chỉ một bản ghi tồn tại
try
{
_db.MonthlyReports.Add(new MonthlyReport { Period = period, Data = data });
await _db.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (ex.IsUniqueViolation())
{
return; // instance khác đã tạo — không phải lỗi
}

Đây là cách chắc chắn nhất, vì nó dựa vào đảm bảo nguyên tử của database thay vì dựa vào giả định về thời gian.

Bảng quyết định:

Bài toánDùng gì
Nhiều worker xử lý hàng đợiSKIP LOCKED hoặc phân vùng
Job định kỳ chỉ chạy một nơiKhoá (mục đích hiệu quả) hoặc leader election
Không được tạo trùng bản ghiUnique constraint
Cần ghi tuần tự vào tài nguyên ngoàiKhoá kèm fencing token

19.8.6 — Rà lại code của bạn​

Danh sách rà soát distributed lock

  • •Đã xác định rõ khoá dùng cho hiệu quả hay cho tính đúng đắn.
  • •Nếu cần tính đúng đắn, có thêm unique constraint hoặc fencing token.
  • •Khoá Redis có token sở hữu, không chỉ có key.
  • •Giải phóng khoá bằng Lua script kiểm tra token, không dùng DEL trực tiếp.
  • •TTL dài hơn thời gian xử lý tối đa dự kiến.
  • •Công việc dài có gia hạn khoá định kỳ thay vì TTL rất lớn.
  • •Đã cân nhắc SKIP LOCKED hoặc phân vùng theo key trước khi dùng khoá.
  • •Đã cân nhắc sp_getapplock nếu đã có SQL Server.
  • •Code xử lý đúng trường hợp không lấy được khoá, không coi đó là lỗi.

Bài tập áp dụng​

Bài 1 — Tái hiện khoá hết hạn giữa chừng​

Lấy khoá TTL 5 giây, Thread.Sleep(10_000) mô phỏng GC pause, và xác nhận một process khác lấy được khoá trong lúc đó.

Tiêu chí hoàn thành: bạn thấy hai process cùng ở trong vùng lẽ ra chỉ một process được vào, và hiểu vì sao không có giá trị TTL nào loại bỏ được khả năng này.

Gợi ý và lời giải — Bài 1

Gợi ý. Khoá hết hạn theo đồng hồ của Redis. Process của bạn không biết điều đó đã xảy ra.

Lời giải — hai tiến trình, một khoá:

// Process A
var handle = await _lock.AcquireAsync("cong-viec", TimeSpan.FromSeconds(5), ct);
Console.WriteLine($"[A] {DateTime.Now:HH:mm:ss.fff} lấy được khoá");

Thread.Sleep(10_000); // mô phỏng GC pause, hoặc máy bị treo I/O

Console.WriteLine($"[A] {DateTime.Now:HH:mm:ss.fff} BẮT ĐẦU công việc (tưởng còn giữ khoá)");
await GhiFileAsync("ket-qua.txt", "A");
await handle.DisposeAsync();
// Process B — khởi động 6 giây sau A
var handle = await _lock.AcquireAsync("cong-viec", TimeSpan.FromSeconds(5), ct);
Console.WriteLine($"[B] {DateTime.Now:HH:mm:ss.fff} lấy được khoá: {handle is not null}");
await GhiFileAsync("ket-qua.txt", "B");
[A] 10:00:00.120  lấy được khoá
[B] 10:00:06.043 lấy được khoá: True <- khoá của A đã hết hạn ở 10:00:05
[A] 10:00:10.135 BẮT ĐẦU công việc (tưởng còn giữ khoá)

Cả A và B đều đang ở trong vùng loại trừ. Không có lỗi nào, không có ngoại lệ — A đơn giản là không có cách nào biết khoá của mình đã hết hạn.

Và khi A kết thúc, cơ chế token sở hữu làm đúng việc của nó:

[A] handle.DisposeAsync() -> Lua script so sánh token -> KHÔNG khớp -> không xoá
-> khoá của B được giữ nguyên

Nếu không có token, A vừa xoá khoá của B — và một process C thứ ba cũng vào được. Đó chính là bài 2 dưới đây.

Vì sao không có TTL nào loại bỏ được khả năng này:

TTL ngắn  -> khoá dễ hết hạn giữa chừng -> loại trừ bị phá vỡ
TTL dài -> nếu process chết, mọi người chờ hết TTL
-> và khả năng phá vỡ vẫn còn, chỉ là hiếm hơn

Nguyên nhân sâu hơn là bốn thứ dưới đây, và không cái nào nằm trong tầm kiểm soát của đoạn code lấy khoá:

Nguồn dừngMức độ điển hình
GC pause gen-2 blockingvài chục ms tới vài giây với heap lớn
Máy ảo bị di chuyển hoặc tạm dừngvài giây
Trao đổi bộ nhớ ra đĩakhông giới hạn trên
Lệch đồng hồ giữa các máytuỳ cấu hình NTP
Khoá phân tán cho bạn LOẠI TRỪ PHẦN LỚN THỜI GIAN.
Nó không cho bạn loại trừ TUYỆT ĐỐI.

Đây chính là điều mục 19.8.1 nói, và bài tập này là bằng chứng chạy được của nó.

Hai cách giảm rủi ro, và một cách loại bỏ hẳn:

1. Gia hạn khoá trong lúc làm việc.

await using var handle = await _lock.AcquireAsync("cong-viec", TimeSpan.FromSeconds(30), ct);
if (handle is null) return;

using var giaHan = new Timer(async _ => await handle.GiaHanAsync(TimeSpan.FromSeconds(30)),
null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
await LamViecAsync(ct);
Giảm rủi ro: khoá không hết hạn vì công việc chạy lâu
KHÔNG loại bỏ: nếu process bị dừng 35 giây, timer cũng bị dừng theo

2. Kiểm tra lại quyền sở hữu ngay trước hành động quan trọng.

if (!await handle.ConGiuAsync(ct))
{
_log.LogWarning("Mất khoá trong lúc chuẩn bị, huỷ lượt này");
return;
}
await GhiKetQuaAsync(ct); // vẫn có khoảng hở giữa kiểm tra và ghi
Thu hẹp cửa sổ từ "toàn bộ thời gian xử lý" xuống "vài mili giây"
-> đủ tốt cho phần lớn nghiệp vụ, nhưng vẫn là XÁC SUẤT

3. Và cách loại bỏ hẳn: đừng dựa vào khoá cho tính đúng đắn.

// Thay vì: khoá rồi ghi
// Hãy: để database đảm bảo tính duy nhất
try
{
_db.KetQua.Add(new KetQua { CongViecId = id, GiaTri = giaTri });
await _db.SaveChangesAsync(ct); // PRIMARY KEY hoặc UNIQUE chặn trùng
}
catch (DbUpdateException ex) when (ex.IsUniqueViolation())
{
return; // ai đó đã làm xong
}
Khoá -> tối ưu hoá: tránh làm việc thừa
Ràng buộc database -> tính đúng đắn: đảm bảo kết quả chỉ có một

Dùng cả hai: khoá để 6 pod không cùng chạy,
ràng buộc để nếu khoá có phá vỡ thì kết quả vẫn đúng.

Đây là cách phân vai đúng, và nó đưa thẳng tới mục 19.8.5 — những lựa chọn tốt hơn khoá.


Bài 2 — Chứng minh cần token sở hữu​

Cài phiên bản giải phóng bằng DEL trực tiếp và tái hiện tình huống xoá nhầm khoá của process khác.

Tiêu chí hoàn thành: bạn tái hiện được ba process cùng vào vùng loại trừ, và hiểu vì sao kiểm tra rồi xoá bằng hai lệnh riêng vẫn chưa đủ.

Gợi ý và lời giải — Bài 2

Gợi ý. Nếu khoá của bạn đã hết hạn và người khác đã lấy, DEL của bạn xoá khoá của họ.

Lời giải — phiên bản sai:

// SAI — giải phóng không kiểm tra chủ sở hữu
private sealed class LockHandleSai(IDatabase db, string key) : IAsyncDisposable
{
public async ValueTask DisposeAsync() => await db.KeyDeleteAsync(key);
}

Kịch bản ba process, TTL 5 giây:

t=0s   A lấy khoá (TTL 5s)
t=0s A bắt đầu việc, và bị GC pause 8 giây
t=5s khoá của A HẾT HẠN, Redis tự xoá
t=5s B lấy khoá thành công (TTL 5s) -> B bắt đầu việc
t=8s A tỉnh dậy, làm xong việc, gọi DisposeAsync -> DEL lock:cong-viec
^^^^ XOÁ KHOÁ CỦA B
t=8s C lấy khoá thành công -> C bắt đầu việc

=> B và C CÙNG chạy, và không ai biết
[A] 10:00:00.102  lấy khoá
[B] 10:00:05.118 lấy khoá: True
[A] 10:00:08.140 giải phóng (DEL)
[C] 10:00:08.201 lấy khoá: True <- lẽ ra phải là False, vì B vẫn đang chạy
[B] 10:00:10.125 đang ghi kết quả
[C] 10:00:10.133 đang ghi kết quả <- hai process cùng ghi

Phiên bản đúng dùng token, và kết quả khác hẳn:

private const string ReleaseScript = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
""";

public async ValueTask DisposeAsync() =>
await db.ScriptEvaluateAsync(ReleaseScript, [key], [token]);
[A] 10:00:08.140  giải phóng -> script trả 0 (token không khớp, không xoá gì)
[C] 10:00:08.201 lấy khoá: False <- đúng, vì khoá của B vẫn còn

Vì sao kiểm tra rồi xoá bằng hai lệnh riêng vẫn chưa đủ:

// VẪN SAI — hai lệnh, có khoảng hở giữa chúng
var hienTai = await db.StringGetAsync(key);
if (hienTai == token)
await db.KeyDeleteAsync(key); // <- khoá có thể đã hết hạn Ở ĐÂY
t=4,999s  GET trả về token của A -> điều kiện đúng
t=5,000s khoá của A hết hạn, Redis xoá
t=5,001s B lấy khoá
t=5,002s DEL của A chạy -> xoá khoá CỦA B

Cửa sổ chỉ vài mili giây — nhưng với hàng nghìn lần lấy khoá mỗi giờ,
"vài mili giây" trở thành "vài lần mỗi ngày".

Lua script loại bỏ khoảng hở này vì Redis thực thi script nguyên tử: trong lúc script chạy, không lệnh nào khác được xen vào.

Bốn điều rút ra từ bài này:

1. Mọi thao tác kiểm-tra-rồi-hành-động trên trạng thái chia sẻ đều cần tính nguyên tử.

Cùng một mẫu lỗi xuất hiện ở:
kiểm tra tồn kho rồi trừ
kiểm tra hạn mức rồi tăng
kiểm tra tồn tại rồi tạo

2. Redis có sẵn công cụ cho từng trường hợp.

ViệcLệnh nguyên tử
Lấy khoáSET key val NX EX ttl
Giải phóng có kiểm traLua script
Tăng bộ đếmINCR
Thêm nếu chưa cóSETNX, HSETNX
Trừ tồn kho có điều kiệnLua script

3. Lỗi loại này không xuất hiện trong test và cũng không xuất hiện khi tải thấp.

Nó cần: một process bị dừng đủ lâu, đúng lúc có process khác đang chờ khoá
-> ở môi trường phát triển: gần như không bao giờ
-> ở production với heap lớn và 20 pod: vài lần mỗi tuần

4. Và vì vậy, cách phòng thủ thật sự nằm ở tầng dưới.

-- Dù khoá có phá vỡ, ràng buộc này vẫn chặn kết quả trùng
ALTER TABLE KetQuaCongViec
ADD CONSTRAINT UQ_KetQua_CongViec UNIQUE (CongViecId);

Khi đã có ràng buộc này, câu hỏi "khoá phân tán có an toàn tuyệt đối không" trở thành câu hỏi về hiệu năng chứ không còn về tính đúng đắn — và đó là chỗ nó nên nằm.


Bài 3 — Bỏ khoá bằng SKIP LOCKED​

Viết hai worker dùng khoá phân tán để lấy job, đo thông lượng, rồi chuyển sang SKIP LOCKED và đo lại.

Tiêu chí hoàn thành: bạn giải thích được vì sao SKIP LOCKED nhanh hơn theo cấu trúc, không chỉ nhanh hơn về con số.

Gợi ý và lời giải — Bài 3

Gợi ý. Với khoá phân tán, N worker tranh nhau một khoá. Với SKIP LOCKED, N worker lấy N job khác nhau và không ai chờ ai.

Lời giải — cách dùng khoá:

// Mỗi worker: lấy khoá -> lấy job -> xử lý -> trả khoá
while (!ct.IsCancellationRequested)
{
await using var khoa = await _lock.AcquireAsync("hang-doi-job", TimeSpan.FromSeconds(30), ct);
if (khoa is null) { await Task.Delay(50, ct); continue; } // worker khác đang giữ

var job = await _db.Jobs
.Where(j => j.TrangThai == TrangThaiJob.Cho)
.OrderBy(j => j.NgayTao)
.FirstOrDefaultAsync(ct);

if (job is null) { await Task.Delay(500, ct); continue; }

job.TrangThai = TrangThaiJob.DangChay;
await _db.SaveChangesAsync(ct);
await XuLyAsync(job, ct);
}

Cách dùng SKIP LOCKED:

-- PostgreSQL và MySQL 8+; SQL Server dùng READPAST
UPDATE Jobs
SET TrangThai = 'DangChay', WorkerId = @workerId, BatDauLuc = NOW()
WHERE Id IN (
SELECT Id FROM Jobs
WHERE TrangThai = 'Cho'
ORDER BY NgayTao
LIMIT 10
FOR UPDATE SKIP LOCKED -- bỏ qua dòng worker khác đang khoá
)
RETURNING *;
-- SQL Server: READPAST cho cùng hành vi
UPDATE TOP (10) Jobs WITH (READPAST, UPDLOCK, ROWLOCK)
SET TrangThai = 'DangChay', WorkerId = @workerId
OUTPUT INSERTED.*
WHERE TrangThai = 'Cho';
var jobs = await _db.Database
.SqlQuery<Job>($"""
UPDATE Jobs SET TrangThai = 'DangChay', WorkerId = {workerId}
WHERE Id IN (SELECT Id FROM Jobs WHERE TrangThai = 'Cho'
ORDER BY NgayTao LIMIT 10 FOR UPDATE SKIP LOCKED)
RETURNING *
""").ToListAsync(ct);

Hình dạng kết quả với 8 worker (mức điển hình — thí nghiệm này cần PostgreSQL hoặc SQL Server, không chạy được trên máy viết bài):

Khoá phân tánSKIP LOCKED
Số worker chạy song song thật sự18
Thông lượng khi tăng workergần như không đổităng gần tuyến tính
Số vòng khứ hồi mỗi job3 (lấy khoá, lấy job, trả khoá)1
Job lấy mỗi lần110 (theo lô)
Thêm hạ tầngRediskhông

Vì sao SKIP LOCKED nhanh hơn theo CẤU TRÚC, không chỉ theo con số:

1. Khoá phân tán biến hàng đợi thành tuần tự.

8 worker, 1 khoá
-> đúng 1 worker chạy tại một thời điểm
-> 7 worker còn lại: lấy khoá thất bại, ngủ 50 ms, thử lại

Thêm worker thứ 9 -> không nhanh hơn chút nào
-> chỉ thêm một tiến trình nữa đi hỏi và bị từ chối

Đây là điểm quan trọng nhất: với khoá, thông lượng không tăng theo số worker. Phần song song đã bị chính cơ chế khoá loại bỏ.

2. Mỗi job tốn ba vòng khứ hồi thay vì một.

Khoá:        Redis SET + database SELECT + database UPDATE + Redis DEL
SKIP LOCKED: một câu UPDATE ... RETURNING

3. Lấy theo lô nhân thêm khác biệt.

LIMIT 10 -> một vòng khứ hồi lấy 10 job
-> chi phí mỗi job giảm thêm một bậc

4. Và một khác biệt về độ bền ít được nói tới: không có khoá nào để rò rỉ.

Worker chết khi đang giữ khoá phân tán
-> mọi worker khác chờ cho tới khi TTL hết

Worker chết khi đang giữ khoá dòng
-> transaction bị huỷ -> khoá dòng được nhả NGAY
-> worker khác lấy được job ở lượt quét kế tiếp

Cơ chế khôi phục nằm sẵn trong database, không phải một tham số phải chỉnh đúng.

Xử lý job "mắc kẹt" — vẫn cần, nhưng đơn giản hơn nhiều:

-- Worker chết SAU khi đã nhận job: trạng thái DangChay kẹt lại
UPDATE Jobs
SET TrangThai = 'Cho', WorkerId = NULL, SoLanThu = SoLanThu + 1
WHERE TrangThai = 'DangChay'
AND BatDauLuc < NOW() - INTERVAL '10 minutes'
AND SoLanThu < 5;

Kết hợp với điều kiện SoLanThu < 5 để một job hỏng vĩnh viễn không quay vòng mãi — sau 5 lần, nó cần được đưa sang hàng đợi thư chết.

Khi nào khoá phân tán VẪN là lựa chọn đúng:

Tình huốngChọn
Lấy job từ hàng đợi, nhiều workerSKIP LOCKED
Một công việc định kỳ chỉ được chạy một bảnKhoá — đúng việc của nó
Migration hoặc khởi tạo chỉ chạy một lầnKhoá
Điều phối giữa các hệ thống khác nhau, không chung databaseKhoá
Cần loại trừ cho tính đúng đắnKhông phải khoá — dùng ràng buộc (bài 2 ở trên)

Hàng thứ hai giải thích vì sao khoá phân tán vẫn thuộc chương trình: chạy OutboxDispatcher trên đúng một pod là bài toán mà SKIP LOCKED không thay thế được — ở đó không có "dòng" nào để khoá, chỉ có một hành động cần được thực hiện một lần.

Nối lại ba bài: bài 1 cho thấy khoá không tuyệt đối, bài 2 cho thấy cài sai thì còn tệ hơn, bài 3 cho thấy phần lớn trường hợp không cần khoá ngay từ đầu. Thứ tự lựa chọn nên là: ràng buộc database trước, SKIP LOCKED tiếp theo, khoá phân tán sau cùng — và chỉ cho đúng loại việc ở bảng trên.

Tự kiểm tra​

Câu hỏi thường gặp

Vì sao khoá phân tán không bao giờ an toàn tuyệt đối?

Vì không thể phân biệt tiến trình đã chết với tiến trình còn sống nhưng bị đứng vì GC pause hay mạng chậm. TTL buộc phải tồn tại để tiến trình chết không giữ khoá vĩnh viễn, và chính TTL đó có thể hết hạn trong lúc chủ sở hữu vẫn đang làm việc.

Hai mục đích dùng khoá khác nhau thế nào?

Dùng cho hiệu quả là để tránh làm trùng việc, và làm trùng chỉ tốn tài nguyên chứ không sai, nên khoá phân tán là đủ. Dùng cho tính đúng đắn là khi làm trùng gây sai dữ liệu hoặc mất tiền, và khi đó khoá một mình không đủ.

Vì sao khoá Redis cần token sở hữu?

Vì không có token, lệnh xoá khoá có thể xoá nhầm khoá của process khác nếu khoá của bạn đã hết hạn và người khác đã lấy được. Khi đó ba process có thể cùng vào vùng được bảo vệ.

Vì sao phải giải phóng khoá bằng Lua script?

Vì kiểm tra token rồi xoá bằng hai lệnh riêng có race condition, khoá có thể hết hạn ngay giữa hai lệnh đó. Lua script chạy nguyên tử trên Redis nên không tồn tại khoảng đó.

Fencing token hoạt động thế nào?

Mỗi lần lấy khoá kèm một số tăng dần, và tài nguyên đích chỉ chấp nhận ghi có token lớn hơn token đã lưu. Process cũ tỉnh dậy ghi với token nhỏ hơn sẽ bị từ chối. Điều kiện là tài nguyên đích phải hỗ trợ kiểm tra đó.

Ba phương án tốt hơn khoá là gì?

Phân vùng theo key để mỗi worker xử lý tập key riêng nên không có tranh chấp, dùng SKIP LOCKED để database tự lo việc khoá, và dựa vào unique constraint với xử lý idempotent. Cả ba loại bỏ nhu cầu khoá thay vì quản lý nó.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Khoá phân tán tốt cho hiệu quả, không đủ cho tính đúng đắn.
  2. Unique constraint của database chắc chắn hơn mọi khoá phân tán, vì nó không dựa vào giả định về thời gian.
  3. Phương án tốt nhất là không cần khoá — phân vùng theo key hoặc SKIP LOCKED.

Tham khảo​

Điều hướng​