Distributed lock với Redis: SET NX, Redisson và giới hạn của nó

4 câu hỏi~30 phútTừ khóa: SET NX PX, lock token, compare-and-delete, Lua, Redisson, watchdog, Redlock, fencing token, ShedLock, flash sale

Tiếp đi: distributed lock với Redis cho job chạy lúc 2 giờ sáng.

Bài 3.5 Race condition đã nói vì sao synchronized không đủ khi chạy nhiều instance và khi nào dùng lock ở DB. Bài này đi sâu phần Redis lock.

1. Bài toán

@Scheduled(cron = "0 0 2 * * *")
public void buildDailyReport() { ... }   // 10 phút, ghi bảng report

shop-app chạy 3 instance → 02:00 cả 3 cùng chạy → report bị ghi 3 lần

2. Vấn đề

3. Giải pháp: SET key token NX PX ttl

Sai: unlock bằng DEL thẳng
A lấy lock (TTL 30s) → A bị GC pause 35s → lock hết hạn
B lấy lock
A tỉnh dậy, DEL lock   ← xóa lock của B!
C lấy lock → B và C cùng chạy

4. Code từng bước

Bước 1: Tự viết với StringRedisTemplate

@Component
@RequiredArgsConstructor
public class RedisLock {

    private static final RedisScript<Long> UNLOCK = RedisScript.of("""
        if redis.call('GET', KEYS[1]) == ARGV[1] then
            return redis.call('DEL', KEYS[1])
        end
        return 0
        """, Long.class);

    private final StringRedisTemplate redis;

    public Optional<String> tryLock(String key, Duration ttl) {
        String token = UUID.randomUUID().toString();
        Boolean ok = redis.opsForValue().setIfAbsent(key, token, ttl);   // SET NX PX
        return Boolean.TRUE.equals(ok) ? Optional.of(token) : Optional.empty();
    }

    public boolean unlock(String key, String token) {
        Long n = redis.execute(UNLOCK, List.of(key), token);             // compare-and-delete
        return n != null && n == 1;
    }
}
@Scheduled(cron = "0 0 2 * * *")
public void buildDailyReport() {
    redisLock.tryLock("lock:job:daily-report", Duration.ofMinutes(15)).ifPresentOrElse(token -> {
        try {
            reportService.build(LocalDate.now().minusDays(1));
        } finally {
            redisLock.unlock("lock:job:daily-report", token);
        }
    }, () -> log.info("Another instance is running daily-report"));
}

Bước 2: Dùng Redisson (thực tế nên dùng thư viện):

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
</dependency>
RLock lock = redisson.getLock("lock:job:daily-report");
if (lock.tryLock(0, TimeUnit.SECONDS)) {        // không chờ, không truyền leaseTime → bật watchdog
    try {
        reportService.build(LocalDate.now().minusDays(1));
    } finally {
        if (lock.isHeldByCurrentThread()) lock.unlock();
    }
}

Watchdog: khi không truyền leaseTime, Redisson đặt TTL 30 giây và cứ ~10 giây gia hạn một lần chừng nào JVM còn sống. Job chạy 10 phút không lo lock hết hạn giữa chừng; JVM chết thì không ai gia hạn, lock tự hết sau tối đa 30 giây. Truyền leaseTime cố định thì watchdog tắt.

Chỉ cần "mỗi job một instance chạy" thì ShedLock (@SchedulerLock) gọn hơn, hỗ trợ cả Redis lẫn JDBC.

Bước 3: Flash sale 1 slot: không cần lock, chỉ cần SET NX làm cổng atomic:

public boolean tryWin(long saleId, long userId) {
    Boolean won = redis.opsForValue().setIfAbsent(
            "sale:" + saleId + ":winner", String.valueOf(userId), Duration.ofDays(1));
    if (Boolean.TRUE.equals(won)) {
        events.publish(new SaleWonEvent(saleId, userId));   // consumer INSERT order vào DB
    }
    return Boolean.TRUE.equals(won);
}

Nhiều slot (100 cái) thì dùng DECR sale:{id}:stock hoặc Lua kiểm tra stock + SADD buyers để chặn mua hai lần.

Bước 4: Test

# 3 instance, chạy job thủ công cùng lúc
for p in 8081 8082 8083; do curl -s -X POST localhost:$p/admin/jobs/daily-report & done; wait
# log: 1 instance "building report", 2 instance "Another instance is running"

redis-cli SET sale:1:winner 42 NX EX 86400   # OK
redis-cli SET sale:1:winner 77 NX EX 86400   # (nil)

5. Cách hoạt động và fencing token

Instance A                Redis                     Instance B
SET lock tA NX PX 30000 ─> OK
build report...           (TTL đếm ngược)
                                               <─ SET lock tB NX → nil
EVAL unlock tA ─────────> GET==tA → DEL
                                               <─ SET lock tB NX → OK

Vấn đề còn lại: A bị pause lâu hơn TTL (GC, mạng), lock hết hạn, B lấy lock, rồi A tỉnh dậy vẫn tưởng mình giữ lock và ghi DB. Token chỉ chặn A xóa nhầm, không chặn A ghi. Cách chặn là fencing token: mỗi lần lấy lock nhận một số tăng dần, storage từ chối write có số cũ hơn.

A lấy lock, fence = 33        B lấy lock, fence = 34
                              B ghi: UPDATE report ... WHERE fence < 34 → ok, fence=34
A tỉnh dậy ghi fence 33  ──>  DB: 33 < 34 → từ chối
long fence = redis.opsForValue().increment("fence:daily-report");   // lấy cùng lúc với lock
int rows = jdbc.update("""
    UPDATE report_status SET fence = ?, status = 'BUILDING'
    WHERE report_date = ? AND fence < ?""", fence, date, fence);
if (rows == 0) throw new StaleLockException();

Redlock: thuật toán lấy lock trên đa số (3/5) Redis master độc lập để tránh mất lock khi failover (bài 7.4). Martin Kleppmann phản biện rằng nó vẫn dựa vào giả định thời gian (clock, pause có giới hạn) và không cung cấp fencing token; antirez (tác giả Redis) phản hồi lại. Kết luận thực dụng: lock để tránh làm trùng việc (efficiency) thì Redis lock một node là đủ; lock để đảm bảo đúng dữ liệu (correctness) thì cần fencing hoặc ràng buộc ở DB.

6. So sánh

CáchƯuNhượcHợp với
synchronizedĐơn giảnChỉ một JVMMột instance
DB lock (SELECT FOR UPDATE, unique, @Version)Có transaction, đúng tuyệt đốiTải dồn lên DB, hot rowDữ liệu tiền, tồn kho ghi DB
Redis SET NX PX + Lua unlockNhanh, nhẹMất lock khi failover, pause > TTLJob, chống làm trùng
Redisson (watchdog)Gia hạn tự động, reentrant, fair lockThêm dependency, vẫn là RedisJob dài, code Java
RedlockChịu được một số node chếtPhức tạp, vẫn tranh cãi, không fencingÍt khi thật sự cần
ZooKeeper / etcdConsensus, có số thứ tự (dùng làm fencing)Vận hành thêm hệ thốngLeader election, correctness cao

7. Khi nào nên dùng / không nên dùng

Câu query atomic ở DB thì mình hiểu rồi. Còn Redis thì nguyên lý thế nào?

  • Database dùng id tự tăng thì sao?
  • Hoặc sort theo create_date thì sao?
  • Application dùng synchronized thì sao?

Đúng, ba hướng đều nghĩ tới được. Nhưng cần tách: cái nào quyết định winner an toàn, cái nào chỉ cho biết thứ tự sau khi dữ liệu đã vào.

Redis SET NX: 1000 request cùng gửi, Redis xử lý tuần tự từng command:

A → SET sale:12h:winner A NX → OK     ← key chưa có
B → SET sale:12h:winner B NX → nil    ← key đã có
C → SET sale:12h:winner C NX → nil

Check + set là một command nên atomic, giống UPDATE sale_slot SET winner='A' WHERE id=1 AND winner IS NULL ở DB. Khác ở chỗ contention dồn vào Redis (RAM, micro giây) thay vì hot row trên DB.

Lưu ý "người nhanh nhất" ở đây là người tới Redis trước, không phải người bấm trước; độ trễ mạng của mỗi người khác nhau.

Với ý tưởng Redis thì hoàn toàn không cần database nhỉ? Nhận request, biết user rồi set lên Redis luôn, người nào tới sau thì không set được.

Sau đó mới cần một flow mời user đó và tạo record trong database.

Đúng, nếu nói riêng bước chọn ai thắng thì hot path không cần chạm DB:

1.000.000 requests
      |
  Application (rate limit, kiểm tra user hợp lệ)
      |
  Redis: SET sale:12h:winner <userId> NX
      |
  ┌───┴────┐
  OK      nil → "Hết hàng"
  |
  Publish SaleWonEvent → Kafka/RabbitMQ
  |
  Consumer → INSERT order (unique sale_id) → mời user thanh toán

Ba điểm cần giữ để an toàn:

Phỏng vấn hỏi "implement distributed lock bằng Redis thế nào, có an toàn không" thì trả lời sao?

Q1. Implement distributed lock bằng Redis thế nào?

🇻🇳 Tiếng Việt:

Em dùng SET key token NX PX ttl: NX để chỉ một client chiếm được, PX để lock tự hết hạn nếu client chết, token ngẫu nhiên để biết ai giữ lock. Unlock bằng Lua script so token rồi mới DEL, tránh xóa nhầm lock của người khác. Trong Java em dùng Redisson, watchdog tự gia hạn lock khi job còn chạy.

🇬🇧 English:

I use SET key token NX PX ttl: NX so only one client acquires it, PX so the lock expires if the client dies, and a random token to identify the owner. To unlock I run a Lua script that compares the token before deleting, so I never delete someone else's lock. In Java I use Redisson, whose watchdog keeps extending the lock while the job is still running.

Q2. Redis lock có an toàn tuyệt đối không?

🇻🇳 Tiếng Việt:

Không. Replication async nên failover có thể làm mất lock; client bị GC pause lâu hơn TTL vẫn tưởng mình giữ lock. Redlock giảm rủi ro failover nhưng vẫn còn tranh cãi. Nếu cần đúng dữ liệu, em thêm fencing token tăng dần và để storage từ chối write cũ, hoặc dùng ràng buộc DB như unique constraint, optimistic lock.

🇬🇧 English:

No. Replication is asynchronous, so a failover can lose the lock, and a client paused by GC longer than the TTL may still believe it holds the lock. Redlock reduces the failover risk but is still debated. When correctness matters I add a monotonically increasing fencing token that the storage checks to reject stale writes, or rely on database constraints such as a unique constraint or optimistic locking.

Q3. Khi nào dùng Redis lock, khi nào dùng DB lock?

🇻🇳 Tiếng Việt:

Redis lock khi mục đích là tránh làm trùng việc, như job định kỳ hay gọi API ngoài. Dữ liệu cần bảo vệ nằm trong DB và liên quan tiền thì em dùng atomic update, SELECT FOR UPDATE hoặc @Version, vì nó nằm cùng transaction. Thường em kết hợp: Redis chặn phần lớn trùng lặp, DB là lớp cuối.

🇬🇧 English:

I use a Redis lock when the goal is to avoid duplicate work, such as scheduled jobs or calls to external APIs. When the data lives in the database and involves money, I use an atomic update, SELECT FOR UPDATE or @Version, because it is part of the same transaction. Often I combine them: Redis filters most duplicates and the database is the last line of defense.

Q4. Flash sale 1 slot, thiết kế chọn người thắng thế nào?

🇻🇳 Tiếng Việt:

Em không để 1 triệu request tranh một row DB. Trước tiên rate limit, sau đó dùng Redis SET sale:winner userId NX làm cổng atomic: request đầu tiên tới Redis thắng, còn lại nhận nil. Winner được publish event để consumer tạo order trong DB có unique constraint trên sale_id. Redis bật AOF và replica, và có job đối soát giữa Redis và DB.

🇬🇧 English:

I would not let a million requests compete for one database row. First I apply rate limiting, then use Redis SET sale:winner userId NX as an atomic gate: the first request to reach Redis wins and the rest get nil. The winner is published as an event, and a consumer creates the order in the database with a unique constraint on sale_id. Redis runs with AOF and replicas, and a reconciliation job compares Redis with the database.

Cách nhớ ngắn gọn


Tổng kết chương 7

BàiÝ chínhCâu phỏng vấn cốt lõi
7.1 Kiến trúc & kiểu dữ liệuIn-memory, thực thi command tuần tự trên main thread, String/Hash/List/Set/ZSet/Stream, TTL, INCR, LuaVì sao Redis nhanh? Single-thread nghĩa là gì?
7.2 Chiến lược cacheCache-aside, DEL sau commit, TTL + jitter, stampede/penetration/avalanche/hot keyCache và DB nhất quán thế nào?
7.3 PersistenceRDB snapshot vs AOF everysec, cache vs primary store, maxmemory-policyRestart Redis có mất data không?
7.4 Replication, Sentinel, ClusterAsync replication, failover, 16384 hash slot, hash tagRedis có gì giống Kafka partition?
7.5 Distributed lockSET NX PX + token, Lua unlock, Redisson watchdog, Redlock, fencingRedis lock có an toàn tuyệt đối không?

Tự kiểm tra

Vì sao lock phải có token ngẫu nhiên?

Để unlock chỉ xóa lock của chính mình; lock đã hết hạn và bị người khác lấy thì không xóa nhầm.

Vì sao unlock phải dùng Lua thay vì GET rồi DEL ở Java?

GET và DEL tách rời thì giữa hai lệnh lock có thể hết hạn và bị client khác lấy. Lua chạy atomic.

Watchdog của Redisson hoạt động khi nào?

Khi không truyền leaseTime: TTL mặc định 30s, gia hạn khoảng mỗi 10s khi JVM còn sống.

Fencing token giải quyết vấn đề gì mà token ngẫu nhiên không giải quyết?

Client bị pause quá TTL vẫn ghi dữ liệu; fencing token tăng dần cho phép storage từ chối write cũ.

Khi nào dùng DB lock thay vì Redis lock?

Dữ liệu cần bảo vệ nằm trong DB và cần đúng tuyệt đối (tiền, tồn kho): atomic update, FOR UPDATE, @Version, unique constraint.