---
title: "Distributed lock là gì, vì sao lock Redis vẫn để dữ liệu bị ghi đè"
description: "Distributed lock giúp nhiều instance không chạy trùng một việc. Redis SET NX chặn được chạy cùng lúc, nhưng lock hết hạn giữa chừng thì cần thêm fencing token."
canonical: "https://200lab.io/blog/distributed-lock-la-gi"
published: "2026-11-14T12:44:46Z"
updated: "2026-11-14T12:44:46Z"
authors: ["Việt Trần"]
tags: ["Backend", "System Design"]
reading_minutes: 17
access: "public"
---

Giả sử service nhắc thanh toán chạy 2 instance, cả hai đều có [cron job](/blog/crontab-la-gi) lúc 9 giờ sáng gửi email nhắc hóa đơn quá hạn. Không có cơ chế phối hợp nên khách nhận cùng một email hai lần.

**Distributed lock là cơ chế để nhiều process trên các máy khác nhau thống nhất chỉ một bên được làm một việc hoặc chạm vào tài nguyên chung tại một thời điểm.**

Trong bài này, mình giải thích cách lấy lock trên Redis, rủi ro khi lock hết hạn giữa chừng, cơ chế fencing token và chạy một demo Go để kiểm chứng.

## Distributed lock là gì và khác mutex ở chỗ nào

Trong một app chạy một process, bạn dùng mutex trong bộ nhớ để các luồng không ghi đè dữ liệu của nhau. Khi chuyển sang [microservices](/blog/microservices-la-gi) hoặc chạy nhiều instance cho một service, các process trên các máy khác nhau không nhìn thấy mutex của nhau, nên cần một nơi trung gian để xin quyền.

Khác biệt lớn nằm ở cách hệ thống phân tán gặp lỗi. Theo Martin Kleppmann, các node và network có thể lỗi độc lập với nhau: một instance giữ lock có thể mất kết nối hoặc dừng đột ngột trong khi các instance khác vẫn chạy.

Vì node giữ lock có thể chết mà không kịp nhả, lock thường đi kèm TTL, tức thời gian sống của key. Hết TTL, lock tự được giải phóng để instance khác làm tiếp thay vì chờ mãi. Vì quyền giữ lock có thời hạn như vậy, Kleppmann gọi nó chính xác hơn là một lease.

Kleppmann chia nhu cầu dùng lock thành hai loại. Lock vì hiệu quả (efficiency) giúp tránh làm một việc tốn kém hai lần; lock lỗi thì user nhận hai email giống nhau hoặc tốn thêm chút tài nguyên. Lock vì đúng đắn (correctness) ngăn các bên làm hỏng dữ liệu của nhau; lock lỗi thì dữ liệu có thể bị ghi đè, mất hoặc lệch vĩnh viễn.

## Lấy lock trên Redis bằng SET NX và TTL

[Redis](/blog/redis-la-gi) hay được dùng để giữ lock vì có sẵn lệnh set có điều kiện và TTL cho key. Để lấy lock trên một Redis instance, theo [tài liệu Distributed Locks with Redis](https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/), bạn dùng lệnh `SET` kết hợp các tham số điều kiện trong một thao tác nguyên tử như sau:

```text
SET resource_name my_random_value NX PX 30000
```

`NX` nghĩa là Redis chỉ tạo key khi key chưa tồn tại, còn `PX 30000` đặt TTL 30000 mili-giây. Lệnh thành công nghĩa là instance đã lấy được lock; trả về rỗng nghĩa là bên khác đang giữ.

![SET NX: chỉ cron-1 lấy được lock và gửi 3 email nhắc, cron-2 bỏ qua](https://assets.200lab.io/sha256/63/63030fa26701d5915c23fbf7761145a8c3ebc66557911518ee06722c496e5d88/generations/b5e107ab27b8b5467e6cf4798487efe8a8de1ac05d42cadf46eb1e573f2cb99d)

Giá trị `my_random_value` phải khác nhau giữa mọi client và mọi lần lấy lock, vì nó được dùng lúc nhả lock. Nếu chỉ gọi `DEL` trần, một instance chạy quá hạn có thể xóa nhầm lock mà instance khác vừa lấy.

Muốn nhả lock đúng, bạn chỉ xóa key khi giá trị trong Redis vẫn khớp với giá trị random của mình. Redis 8.4 có lệnh `DELEX key IFEQ my_random_value` cho việc này; với bản cũ hơn như 8.0.2 trong bài, tài liệu Redis dùng Lua script so `GET` rồi mới `DEL`:

```text
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end
```

## Lock hết hạn giữa chừng và vai trò của fencing token

Việc đặt TTL và dùng Lua script giúp tránh xóa nhầm lock, nhưng chưa giải quyết được bài toán lock hết hạn khi việc chưa xong. Giả sử instance A lấy lock có TTL 10 giây nhưng bị khựng lại 15 giây do GC pause, hệ điều hành nạp trang bộ nhớ (page fault), tranh chấp CPU hoặc network bị trễ.

Qua mốc 10 giây, Redis tự xóa key nên instance B lấy được lock và ghi dữ liệu. Đến giây 15, A tỉnh dậy, không biết lock đã hết hạn và vẫn ghi, nên dữ liệu cũ của A đè lên dữ liệu mới của B. Theo Kleppmann, kiểm tra hạn lock ngay trước khi ghi cũng không đủ, vì process có thể bị dừng đúng giữa lúc kiểm tra và lúc ghi.

Cách Kleppmann đưa ra là fencing token: một con số tăng lên mỗi lần có client lấy được lock. Client gửi token kèm mỗi lệnh ghi, còn nơi lưu dữ liệu nhớ token lớn nhất đã thấy và từ chối lệnh ghi mang token nhỏ hơn. Trong ví dụ trên, A cầm token 1, B cầm token 2, nên lệnh ghi muộn của A bị chặn.

![Fencing token: storage từ chối lệnh ghi trễ của worker-A mang token nhỏ hơn](https://assets.200lab.io/sha256/49/49c8645177e40aff93ae99594d3e61a88c726395d67532cce4d6f9f42a98ad44/generations/3f125a89f5d5a2402bc75fae3bc884dfbc92883eebce805efb8d00767df3c86e)

Điều kiện là chính nơi lưu dữ liệu phải chủ động so token; storage nhận ghi mà không so thì token chẳng có tác dụng. Đây là điểm dễ sót khi làm [system design](/blog/system-design-la-gi): lock nằm ở một nơi, còn việc chặn lệnh ghi trễ lại nằm ở nơi lưu dữ liệu.

## Chạy thử distributed lock với Go và Redis 8.0.2

Mình viết một chương trình chạy trên máy, không phải production: Go 1.27.3 linux/amd64, Redis 8.0.2 chạy local ở `127.0.0.1:6379` không có replica, thư viện `github.com/redis/go-redis/v9` v9.23.0. Email và storage là struct giả lập trong bộ nhớ, GC pause được mô phỏng bằng `time.Sleep`.

Có 3 hóa đơn `INV-101`, `INV-102`, `INV-103`, mỗi email giả lập mất 50 ms. Hai instance `cron-1` và `cron-2` là hai [goroutine](/blog/goroutines-la-gi) khởi động cùng lúc, còn fencing token được lấy bằng lệnh `INCR` trên chính Redis đó.

Chúng ta thử xem đoạn code dưới đây nha:

```go
// distributed_lock.go: two cron instances send payment reminders.
// Path A: no lock. Path B: SET NX lock with a TTL.
// Path C: the lock expires while the holder is paused, with and without a fencing token.
package main

import (
	"context"
	"fmt"
	"strings"
	"sync"
	"time"

	"github.com/redis/go-redis/v9"
)

var ctx = context.Background()

const lockKey = "lock:payment-reminder"
const fenceKey = "fence:payment-reminder"

var invoices = []string{"INV-101", "INV-102", "INV-103"}

// Mailer simulates the email provider and counts every email sent.
type Mailer struct {
	mu   sync.Mutex
	sent map[string]int
}

func (m *Mailer) Send(invoice string) {
	m.mu.Lock()
	defer m.mu.Unlock()
	m.sent[invoice]++
}

func (m *Mailer) Total() int {
	m.mu.Lock()
	defer m.mu.Unlock()
	n := 0
	for _, c := range m.sent {
		n += c
	}
	return n
}

// Storage simulates a resource that remembers the highest fencing token it has seen.
type Storage struct {
	mu         sync.Mutex
	checkFence bool
	lastToken  int64
	value      string
}

func (s *Storage) Write(token int64, value string) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.checkFence && token < s.lastToken {
		return false // stale token: reject the write
	}
	if token > s.lastToken {
		s.lastToken = token
	}
	s.value = value
	return true
}

// Release deletes the lock only if it still holds our random value.
var releaseScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
end
return 0`)

func acquire(rdb *redis.Client, owner string, ttl time.Duration) bool {
	ok, err := rdb.SetNX(ctx, lockKey, owner, ttl).Result()
	if err != nil {
		panic(err)
	}
	return ok
}

// runJob sends one reminder per invoice; each send takes 50ms.
func runJob(m *Mailer) {
	for _, inv := range invoices {
		time.Sleep(50 * time.Millisecond)
		m.Send(inv)
	}
}

// startTogether runs fn for each worker at the same moment.
func startTogether(workers []string, fn func(w string)) {
	var wg sync.WaitGroup
	start := make(chan struct{})
	for _, w := range workers {
		wg.Add(1)
		go func(w string) {
			defer wg.Done()
			<-start
			fn(w)
		}(w)
	}
	close(start)
	wg.Wait()
}

func pathA(rounds int) {
	fmt.Println("== Path A: no lock, 2 instances")
	dup := 0
	var last *Mailer
	for r := 0; r < rounds; r++ {
		m := &Mailer{sent: map[string]int{}}
		startTogether([]string{"cron-1", "cron-2"}, func(w string) { runJob(m) })
		if m.Total() > len(invoices) {
			dup++
		}
		last = m
	}
	fmt.Printf("last round: emails sent=%d for %d invoices %v\n", last.Total(), len(invoices), last.sent)
	fmt.Printf("rounds with duplicate emails: %d/%d\n", dup, rounds)
}

func pathB(rdb *redis.Client, rounds int) {
	fmt.Println("== Path B: SET NX with 5s TTL, 2 instances")
	dup, skipped := 0, 0
	var last *Mailer
	var mu sync.Mutex
	for r := 0; r < rounds; r++ {
		m := &Mailer{sent: map[string]int{}}
		startTogether([]string{"cron-1", "cron-2"}, func(w string) {
			owner := fmt.Sprintf("%s-round-%d", w, r)
			if !acquire(rdb, owner, 5*time.Second) {
				mu.Lock()
				skipped++
				mu.Unlock()
				return // another instance holds the lock
			}
			runJob(m)
			releaseScript.Run(ctx, rdb, []string{lockKey}, owner)
		})
		if m.Total() > len(invoices) {
			dup++
		}
		last = m
	}
	fmt.Printf("last round: emails sent=%d for %d invoices %v\n", last.Total(), len(invoices), last.sent)
	fmt.Printf("rounds with duplicate emails: %d/%d, skipped attempts: %d\n", dup, rounds, skipped)
}

// pathC: worker-A takes a 300ms lock, then pauses 600ms (think GC pause).
// worker-B takes the expired lock at 350ms and keeps it for 2s.
func pathC(rdb *redis.Client, useFence bool) {
	if useFence {
		fmt.Println("== Path C2: lock expires mid-job, fencing token + safe release")
	} else {
		fmt.Println("== Path C1: lock expires mid-job, no fencing token + plain DEL")
	}
	rdb.Del(ctx, lockKey, fenceKey)
	st := &Storage{checkFence: useFence}
	var wg sync.WaitGroup
	wg.Add(2)

	go func() { // worker-A
		defer wg.Done()
		if !acquire(rdb, "worker-A", 300*time.Millisecond) {
			return
		}
		token := rdb.Incr(ctx, fenceKey).Val()
		fmt.Printf("worker-A got lock, token=%d, ttl=300ms\n", token)
		time.Sleep(600 * time.Millisecond) // paused longer than the TTL
		ok := st.Write(token, "status written by worker-A")
		fmt.Printf("worker-A wakes up, writes with token=%d -> accepted=%v\n", token, ok)
		if useFence {
			n := releaseScript.Run(ctx, rdb, []string{lockKey}, "worker-A").Val()
			fmt.Printf("worker-A safe release deleted keys=%v\n", n)
		} else {
			n := rdb.Del(ctx, lockKey).Val()
			fmt.Printf("worker-A plain DEL deleted keys=%d\n", n)
		}
		owner, err := rdb.Get(ctx, lockKey).Result()
		if err == redis.Nil {
			owner = "(none)"
		}
		fmt.Printf("lock owner after worker-A release: %s\n", owner)
	}()

	go func() { // worker-B
		defer wg.Done()
		time.Sleep(350 * time.Millisecond)
		if !acquire(rdb, "worker-B", 2*time.Second) {
			fmt.Println("worker-B could not get lock")
			return
		}
		token := rdb.Incr(ctx, fenceKey).Val()
		fmt.Printf("worker-B got lock after expiry, token=%d\n", token)
		ok := st.Write(token, "status written by worker-B")
		fmt.Printf("worker-B writes with token=%d -> accepted=%v\n", token, ok)
	}()

	wg.Wait()
	fmt.Printf("final value in storage: %q\n", st.value)
	rdb.Del(ctx, lockKey, fenceKey)
}

func main() {
	rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
	defer rdb.Close()
	for _, line := range strings.Split(rdb.Info(ctx, "server").Val(), "\r\n") {
		if strings.HasPrefix(line, "redis_version:") {
			fmt.Println(line)
		}
	}
	rdb.Del(ctx, lockKey, fenceKey)
	pathA(100)
	pathB(rdb, 100)
	pathC(rdb, false)
	pathC(rdb, true)
}
```

```text
$ go version
go version go1.27.3 linux/amd64
$ go run .
redis_version:8.0.2
== Path A: no lock, 2 instances
last round: emails sent=6 for 3 invoices map[INV-101:2 INV-102:2 INV-103:2]
rounds with duplicate emails: 100/100
== Path B: SET NX with 5s TTL, 2 instances
last round: emails sent=3 for 3 invoices map[INV-101:1 INV-102:1 INV-103:1]
rounds with duplicate emails: 0/100, skipped attempts: 100
== Path C1: lock expires mid-job, no fencing token + plain DEL
worker-A got lock, token=1, ttl=300ms
worker-B got lock after expiry, token=2
worker-B writes with token=2 -> accepted=true
worker-A wakes up, writes with token=1 -> accepted=true
worker-A plain DEL deleted keys=1
lock owner after worker-A release: (none)
final value in storage: "status written by worker-A"
== Path C2: lock expires mid-job, fencing token + safe release
worker-A got lock, token=1, ttl=300ms
worker-B got lock after expiry, token=2
worker-B writes with token=2 -> accepted=true
worker-A wakes up, writes with token=1 -> accepted=false
worker-A safe release deleted keys=0
lock owner after worker-A release: worker-B
final value in storage: "status written by worker-B"
```

Ở nhánh Path A không có lock, vòng cuối gửi 6 email cho 3 hóa đơn, mỗi hóa đơn 2 lần. Lặp 100 vòng thì cả 100/100 vòng đều gửi trùng.

Ở nhánh Path B có `SET NX` với TTL 5 giây, vòng cuối chỉ gửi 3 email, mỗi hóa đơn 1 lần. Trong 100 vòng có 0/100 vòng gửi trùng và 100 lần một instance không lấy được lock nên bỏ qua.

Path B chỉ chặn hai instance chạy cùng lúc. Nếu instance thứ nhất xong và nhả lock trước khi instance thứ hai bắt đầu, instance thứ hai vẫn lấy được lock và gửi lại. Muốn chặn gửi trùng giữa các lần chạy, bạn cần đánh dấu đã gửi theo từng hóa đơn, cùng ý tưởng với [idempotency key](/blog/idempotency-key-la-gi).

Nhánh Path C1 cho lock hết hạn giữa chừng, không có fencing token và nhả lock bằng `DEL` trần. `worker-A` lấy lock TTL 300 ms với `token=1` rồi bị dừng 600 ms; đến mốc 350 ms, `worker-B` lấy được lock với `token=2` và ghi thành công. `worker-A` tỉnh dậy vẫn ghi được với `token=1`, nên giá trị cuối là `"status written by worker-A"`. Lệnh `DEL` của nó còn xóa luôn lock đang thuộc `worker-B`, chủ lock sau đó là `(none)`.

Nhánh Path C2 chạy lại kịch bản đó, nhưng storage có kiểm tra fencing token và lock được nhả bằng Lua script. Lệnh ghi muộn của `worker-A` mang `token=1` bị từ chối (`accepted=false`). Script nhả lock xóa 0 key vì giá trị random không khớp, chủ lock vẫn là `worker-B` và dữ liệu cuối là `"status written by worker-B"`.

![DEL trần xóa nhầm lock của worker-B, Lua script so giá trị nên xóa 0 key](https://assets.200lab.io/sha256/79/797731152cd13a2ed0f2d5747d78174843f2976c011efb5104672de49f20df0d/generations/bf2019cd42feb59fbd2f3feb0b8b79a7bfd26451154b134492a9a296e8918ee5)

Token lấy bằng `INCR` trên một Redis node chỉ để minh họa cách so số; theo Kleppmann, counter trên một node là chưa đủ vì node đó có thể lỗi. Demo cũng chỉ chạy 1 Redis instance, không thử failover, không chạy Redlock và không đo hiệu năng.

## Redlock và tranh luận giữa Kleppmann với antirez

Một Redis instance giữ lock là single point of failure, tức sập là cả hệ thống không lấy được lock. Tài liệu Redis cảnh báo việc thêm replica để failover cũng không giữ được tính loại trừ, vì replication là bất đồng bộ: master có thể chết trước khi key kịp sang replica, replica lên thay và cấp lock đó cho client khác.

Vì vậy, tài liệu Redis giới thiệu Redlock, thuật toán chạy trên N Redis master độc lập, ví dụ 5 node. Client gửi lệnh xin lock song song tới cả 5 node với cùng key và giá trị random, và chỉ coi là lấy được lock khi giữ được đa số, tức ít nhất 3 node, trong thời gian ngắn hơn hạn của lock.

![Redlock: client chỉ coi là có lock khi giữ được ít nhất 3/5 Redis master](https://assets.200lab.io/sha256/06/065f18887e4ebe049500deb48f58d8a80c98e2e0fbdfdc48cd7ab0bdfeebcd88/generations/ac0d87ef75370395968780162d4bb22398cb9145771afb4cb4f20aea3de61a96)

Cùng trang đó cũng khuyên dùng fencing token, đặc biệt với tác vụ chạy lâu, và lưu ý Redis không dùng monotonic clock để tính TTL. Nếu đồng hồ hệ thống bị nhảy, lock có thể bị nhiều process cùng giữ.

Ngày 8/2/2016, Martin Kleppmann phân tích Redlock trong bài [How to do distributed locking](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html). Với lock vì hiệu quả, ông cho rằng 5 node Redlock là thừa: một Redis instance với `SET NX` và xóa có điều kiện là đủ, miễn ghi rõ lock chỉ gần đúng. Với lock vì đúng đắn, ông khuyên không dùng Redlock vì nó dựa vào giả định về độ trễ network, thời gian process bị dừng, sai lệch đồng hồ, và không sinh fencing token.

Khi bắt buộc phải giữ tính đúng đắn cho dữ liệu, Kleppmann đề xuất dùng hệ thống đồng thuận như ZooKeeper, có thể lấy zxid hoặc version của znode làm fencing token. Ít nhất, theo ông, hãy dùng một database có transaction đáng tin cậy, kiểu database đáp ứng [ACID](/blog/acid-la-gi).

Một ngày sau, Salvatore Sanfilippo, tức antirez, tác giả Redis và Redlock, phản hồi trong bài [Is Redlock safe?](http://antirez.com/news/101). Ông cho rằng nếu storage đã kiểm tra được fencing token tăng dần thì nó gần như đã là một store linearizable. Theo ông, client có thể dùng chính giá trị random của mỗi lần lấy lock để làm check-and-set mà không cần số tăng dần.

Về độ trễ network, antirez giải thích Redlock đo lại thời gian đã trôi sau khi lấy được đa số node, quá hạn thì coi như không lấy được lock. Ông đồng ý Redis nên chuyển sang monotonic clock, nhưng không thấy kết luận "đừng dùng Redlock khi cần loại trừ" của Kleppmann là có cơ sở.

Bài này không phán bên nào đúng. Điều anh em nên mang về là câu hỏi cốt lõi: lock của mình dùng vì hiệu quả hay vì tính đúng đắn? Nếu một lần ghi đè cũng làm hỏng dữ liệu, bạn không nên phó mặc cho tầng lock mà phải thêm lớp kiểm tra ở nơi ghi dữ liệu như fencing token, ràng buộc database hoặc xử lý trùng lặp.

## Khi nào không nên dùng distributed lock và sai lầm hay gặp

Sai lầm đầu tiên là nhả lock bằng `DEL` trần như Path C1, khiến process chạy chậm xóa mất lock của process đến sau. Sai lầm thứ hai là đặt TTL ngắn hơn thời gian chạy job mà không gia hạn, hoặc tin rằng process còn sống thì lock vẫn còn.

Sai lầm thứ ba là bỏ qua fencing token hay ràng buộc ở nơi ghi khi ghi trùng làm hỏng dữ liệu. Nếu dữ liệu nằm trong [PostgreSQL](/blog/postgresql-la-gi), nhiều khi bạn không cần lock trên Redis: database đã có ràng buộc `UNIQUE`, `SELECT ... FOR UPDATE`, [transaction và isolation level](/blog/transaction-isolation-level-la-gi).

Với worker đọc message từ queue, [idempotent consumer](/blog/idempotent-consumer-la-gi) thường hợp hơn một lock bọc ngoài. Còn khi nghiệp vụ cập nhật nhiều nơi lưu dữ liệu, một lock bên ngoài là chưa đủ; bạn cần xem các cách làm [distributed transaction](/blog/distributed-transaction-la-gi).

Bạn cũng không cần dựng Redlock 5 node khi mục tiêu chỉ là tránh chạy trùng một cron job, và nên tránh giữ lock quá lâu làm nghẽn các instance khác. Nếu lấy lock thất bại và cần thử lại, tài liệu Redis khuyên chờ một khoảng ngẫu nhiên rồi mới thử lại, để các client không cùng lúc tranh lock.

Mình chọn một Redis instance với `SET NX`, TTL và nhả lock bằng Lua script khi lock chỉ để tránh làm trùng việc tốn kém như gửi email nhắc, như demo giảm từ 6 email xuống 3. Khi một lần ghi trùng có thể làm hỏng dữ liệu, mình không dựa vào lock một mình mà thêm fencing token hoặc ràng buộc ở database như nhánh C2, và cân nhắc hệ đồng thuận như ZooKeeper theo khuyến nghị của Kleppmann.

## Đọc thêm

- [Crontab là gì](/blog/crontab-la-gi): hiểu cách đặt lịch cho các tác vụ định kỳ trên server trước khi phối hợp nhiều instance.
- [Redis là gì](/blog/redis-la-gi): ôn các tính năng chính của Redis, nơi demo này đặt lock.
- [Idempotency key là gì](/blog/idempotency-key-la-gi): cách thiết kế API và tầng lưu trữ để việc chạy lặp lại không làm trừ tiền hay gửi trùng dữ liệu.
- [Transaction và isolation level](/blog/transaction-isolation-level-la-gi): tận dụng cơ chế khóa và cô lập giao dịch có sẵn trong database để chống ghi đè dữ liệu.

## Đọc thêm

- [Rate limiting là gì? So sánh Fixed Window, Sliding Window, Token Bucket](https://200lab.io/blog/rate-limiting-la-gi.md): Rate limiting kiểm soát tần suất request và trả về mã HTTP 429 khi vượt ngưỡng. Xem so sánh 3 thuật toán cùng cách chọn key và đặt limiter.
- [CRDT là gì? Cách đồng bộ dữ liệu khi nhiều người cùng sửa](https://200lab.io/blog/crdt-la-gi.md): CRDT giúp đồng bộ dữ liệu khi nhiều người cùng chỉnh sửa lúc mất mạng. Các thay đổi được kết hợp ra sao, và xung đột được xử lý thế nào?
- [Composite index là gì? Thứ tự cột và EXPLAIN trên MySQL, PostgreSQL](https://200lab.io/blog/composite-index-la-gi.md): Composite index là gì, vì sao tạo index rồi mà query vẫn Seq Scan? Cùng thử trên PostgreSQL, MySQL để nắm quy tắc đặt cột bằng trước, cột khoảng sau.
- [Transaction là gì, isolation level là gì? Chạy thử trên PostgreSQL, MySQL](https://200lab.io/blog/transaction-isolation-level-la-gi.md): Transaction là gì mà trừ kho vẫn sai? Chạy thử 4 mức isolation level trên PostgreSQL, MySQL để hiểu dirty read, phantom read, MVCC và cách chặn lost update.
- [Reverse proxy là gì? Cách cấu hình Nginx và phân biệt với API gateway](https://200lab.io/blog/reverse-proxy-la-gi.md): Reverse proxy là gì, khác forward proxy và API gateway ra sao? Cấu hình reverse proxy Nginx với proxy_pass, upstream, X-Forwarded-For và cách xử lý lỗi 502.
- [Mọi bài viết chủ đề Backend](https://200lab.io/blog/tags/backend.md)

Toàn bộ nội dung: https://200lab.io/llms.txt
