Cache có dung lượng hữu hạn, nên khi đầy phải loại bớt. LRU (Least Recently Used) là chính sách loại bỏ phổ biến nhất: bỏ phần tử lâu nhất chưa được dùng, vì thứ lâu không đụng tới nhiều khả năng sẽ không cần nữa. Nghe đơn giản, nhưng cài đúng và nhanh thì cần một mẹo hay: bạn phải làm hai việc đều trong O(1) — tra cứu một key, và cập nhật "thứ tự dùng gần đây".

Không cấu trúc đơn lẻ nào làm được cả hai: map tra cứu O(1) nhưng không biết thứ tự; danh sách liên kết đôi giữ thứ tự và chèn/xóa O(1) nhưng tra cứu O(n). Lời giải kinh điển là kết hợp cả hai — map trỏ thẳng vào node của danh sách. Bài này tự cài LRU cache từ đầu trong Go, đo thật hành vi loại bỏ và chứng minh get/put là O(1).

Ý tưởng: hai cấu trúc bù nhau

// LRU = Least Recently Used: khi đầy, loại phần tử LÂU NHẤT chưa dùng.
// Cần 2 việc đều O(1): (1) tra cứu theo key, (2) cập nhật thứ tự dùng.
// map -> tra cứu O(1) nhưng KHÔNG có thứ tự.
// danh sách liên kết đôi -> chèn/xóa/đẩy-lên-đầu O(1) nhưng tra cứu O(n).
// Kết hợp: map trỏ vào node của list -> cả hai việc đều O(1).

Cấu trúc

type LRU struct {
	cap   int
	ll    *list.List            // đầu = mới nhất, cuối = ít dùng nhất
	items map[int]*list.Element // key -> node trong list
}

container/list của Go là một danh sách liên kết đôi sẵn dùng. Quy ước: đầu danh sách là phần tử mới dùng nhất, cuối là ít dùng nhất. Map items ánh xạ key sang chính node trong danh sách — đây là cầu nối làm mọi thứ O(1).

Get: tra map rồi đẩy node lên đầu

func (c *LRU) Get(key int) (int, bool) {
	if el, ok := c.items[key]; ok {
		c.ll.MoveToFront(el) // dùng -> đẩy lên đầu (mới nhất)
		return el.Value.(*entry).val, true
	}
	return 0, false
}

Truy cập một key vừa trả giá trị, vừa đánh dấu nó là "vừa dùng" bằng cách đẩy node lên đầu danh sách. Vì map cho ta node trực tiếp, MoveToFront chỉ nối lại vài con trỏ — O(1), không quét.

Put: thêm vào đầu, loại cuối khi vượt sức chứa

func (c *LRU) Put(key, val int) {
	if el, ok := c.items[key]; ok { // đã có -> cập nhật + đẩy đầu
		el.Value.(*entry).val = val
		c.ll.MoveToFront(el); return
	}
	el := c.ll.PushFront(&entry{key, val}) // thêm vào đầu
	c.items[key] = el
	if c.ll.Len() > c.cap { // vượt sức chứa
		last := c.ll.Back()  // node cuối = ít dùng nhất
		c.ll.Remove(last)
		delete(c.items, last.Value.(*entry).key) // loại khỏi CẢ list và map
	}
}

Điểm dễ sai nhất khi tự cài: khi loại bỏ, phải xóa phần tử khỏi cả danh sách và map. Quên một trong hai gây rò rỉ bộ nhớ hoặc key "ma" trỏ vào node đã xóa. list.Back() cho node cuối (ít dùng nhất) trong O(1), và ta lấy key từ node đó để xóa khỏi map.

Ảnh chụp đoạn mã Go nền tối minh hoạ LRU cache tự cài trong Go get put O(1) bằng map và danh sách liên kết đôi, ý tưởng hai cấu trúc bù nhau LRU Least Recently Used khi đầy loại phần tử lâu nhất chưa dùng cần 2 việc đều O(1) tra cứu theo key và cập nhật thứ tự dùng map tra cứu O(1) nhưng không có thứ tự danh sách liên kết đôi chèn xóa đẩy lên đầu O(1) nhưng tra cứu O(n) kết hợp map trỏ vào node của list cả hai việc đều O(1), cấu trúc type LRU struct cap int ll trỏ list.List đầu là mới nhất cuối là ít dùng nhất items map int trỏ list.Element key trỏ node trong list, Get tra map đẩy node lên đầu func Get key int int bool if el ok bằng c.items key ok c.ll MoveToFront el dùng đẩy lên đầu mới nhất return el Value entry val true return 0 false, Put thêm vào đầu loại cuối khi vượt sức chứa func Put key val int if el ok bằng c.items key ok đã có cập nhật cộng đẩy đầu el Value entry val bằng val c.ll MoveToFront el return el bằng c.ll PushFront entry key val thêm vào đầu c.items key bằng el if c.ll Len lớn hơn c.cap vượt sức chứa last bằng c.ll Back node cuối ít dùng nhất c.ll Remove last delete c.items last Value entry key loại khỏi cả list và map

Hình 1: LRU cache tự cài — map ánh xạ key sang node của danh sách liên kết đôi; Get đẩy node lên đầu (đánh dấu vừa dùng), Put thêm vào đầu và loại node cuối (ít dùng nhất) khỏi cả list và map khi vượt sức chứa — mọi thao tác O(1).

Đo thật: hành vi loại bỏ và hiệu năng

Dựng LRU sức chứa 3, minh họa loại bỏ:

Put 1,2,3 -> cache đầy [3,2,1] (mới->cũ)
Get(1)    -> đẩy 1 lên đầu: [1,3,2]
Put(4,40) -> loại 2 (ít dùng nhất): [4,1,3]
Get(2) còn? false (đã bị loại) | Get(4)=40 có? true
Thứ tự hiện tại (mới->cũ): 4 1 3
Tổng số lần loại bỏ: 1

Đọc kỹ hành vi: sau khi thêm 1,2,3 thì thứ tự là [3,2,1] (3 mới nhất). Get(1) đẩy 1 lên đầu thành [1,3,2] — giờ 2 là ít dùng nhất. Khi Put(4,40) làm cache vượt sức chứa 3, nó loại đúng 2 (ít dùng nhất), không phải 1 hay 3. Get(2) trả false (đã bị loại), Get(4) trả 40. Chính hành động Get(1) đã "cứu" 1 khỏi bị loại — đó là bản chất của LRU: dùng gần đây thì sống lâu hơn.

Về hiệu năng, benchmark một vòng Put (có loại bỏ khi vượt sức chứa) + Get:

BenchmarkLRUPutGet   79.60 ns/op   64 B/op   2 allocs/op

79.6 ns cho cả put lẫn get, và quan trọng nhất: thời gian này không tăng theo số phần tử trong cache — đó là O(1) thật sự. Bí quyết là map cho tra cứu O(1) cộng với danh sách liên kết đôi cho đẩy/xóa node O(1); vì map trỏ thẳng vào node, ta không bao giờ phải quét danh sách.

Ảnh chụp bảng kết quả đo thật nền tối LRU cache tự cài trong Go chạy bằng go run cộng go test bench Go 1.23 arm64 container list, LRU sức chứa 3 minh họa loại bỏ Put 1 2 3 cache đầy 3 2 1 mới tới cũ Get 1 đẩy 1 lên đầu 1 3 2 Put 4 40 loại 2 ít dùng nhất 4 1 3 Get 2 còn false đã bị loại Get 4 bằng 40 có true thứ tự hiện tại mới tới cũ 4 1 3 tổng số lần loại bỏ 1 Get 1 cứu 1 khỏi bị loại 2 thành ít dùng nhất nên bị loại khi đầy, hiệu năng get cộng put là O(1) hằng số BenchmarkLRUPutGet 79.60 ns mỗi op 64 B mỗi op 2 allocs mỗi op mỗi vòng 1 Put có loại bỏ khi vượt sức chứa cộng 1 Get thời gian không tăng theo số phần tử O(1) thật sự map tra cứu O(1) cộng list đẩy xóa node O(1) bằng tổng O(1), vì sao cần cả hai cấu trúc chỉ map tra cứu O(1) nhưng không biết cái nào lâu chưa dùng chỉ list biết thứ tự nhưng tìm 1 key phải quét O(n) map cộng list map trỏ thẳng vào node xóa đẩy node đó O(1) không quét, cốt lõi LRU đầy thì loại phần tử lâu nhất chưa dùng cấu trúc map tra cứu cộng danh sách liên kết đôi thứ tự dùng O(1) cả Get và Put đo thật 79.6 ns mỗi op kể cả khi loại bỏ then chốt map trỏ vào node list cập nhật node đó không cần quét an toàn bản này không khóa nhiều goroutine cần bọc sync Mutex đánh đổi LRU không hợp mọi tải quét tuần tự lớn làm hỏng cache

Hình 2: Đo thật LRU sức chứa 3 — loại đúng phần tử 2 (ít dùng nhất sau khi Get(1) cứu 1), thứ tự cuối 4 1 3; benchmark get+put 79.6 ns/op không tăng theo số phần tử, chứng minh O(1) hằng số nhờ map trỏ vào node danh sách.

Đánh đổi cần cân nhắc

Bản này không an toàn cho đồng thời — phải tự bọc khóa. Code trên không có khóa; nhiều goroutine cùng gọi Get/Put sẽ đua tranh trên cả map lẫn list (chạy với -race sẽ tố cáo ngay). Để dùng đa luồng, bọc mọi thao tác trong sync.Mutex. Lưu ý: ngay cả Get cũng ghi (nó gọi MoveToFront), nên không thể dùng RWMutex với RLock cho Get — phải Lock đầy đủ, hoặc thiết kế lại (ví dụ cập nhật thứ tự kiểu xấp xỉ). Đây là lý do các thư viện LRU sản xuất (như hashicorp/golang-lru) có phiên bản khóa sẵn.

LRU không phải chính sách tốt nhất cho mọi tải. LRU giả định "vừa dùng thì sắp dùng lại" (tính cục bộ thời gian). Đúng cho nhiều tải, nhưng có ca xấu: một lần quét tuần tự qua tập dữ liệu lớn hơn cache sẽ đẩy hết phần tử nóng ra ngoài (cache pollution) mà không phần tử quét nào được dùng lại — làm hỏng cache. Với tải như vậy, các chính sách như LFU (theo tần suất) hoặc ARC/2Q (chống quét) tốt hơn — nhưng phức tạp hơn để cài.

Chi phí bộ nhớ cho cấu trúc phụ. Mỗi phần tử tốn thêm một node danh sách (hai con trỏ) và một mục map, ngoài giá trị thật. Với phần tử nhỏ (như int), overhead này đáng kể so với dữ liệu. LRU đáng dùng khi giá trị được cache đắt để tính lại hoặc lấy về (query DB, gọi API), nơi tiết kiệm nhờ hit áp đảo overhead cấu trúc.

Ba ý mang về

  1. LRU cache cần cả tra cứu và cập nhật thứ tự đều O(1) — không cấu trúc đơn lẻ nào làm được, nên kết hợp map (tra cứu O(1)) với danh sách liên kết đôi (thứ tự dùng, chèn/xóa O(1)), map trỏ thẳng vào node để không phải quét.
  2. Loại đúng phần tử ít dùng nhất, O(1) hằng số: đo thật, cache sức chứa 3 loại đúng phần tử 2 (ít dùng nhất sau khi Get(1) cứu 1), và benchmark cho 79.6 ns/op cho cả put+get kể cả khi có loại bỏ — thời gian không tăng theo số phần tử.
  3. Nêu rõ đánh đổi: bản cơ bản không an toàn đồng thời (phải bọc sync.Mutex, và Get cũng ghi nên không RLock được), LRU bị quét tuần tự làm hỏng cache (cân nhắc LFU/ARC cho tải đó), và có overhead bộ nhớ cho node+map nên chỉ đáng khi giá trị cache đắt để lấy lại.

Bài tập làm thử

Bài 1 (đọc hiểu). LRU cache sức chứa 3 vừa thực hiện Put(1,10), Put(2,20), Put(3,30) theo thứ tự đó (không có thao tác Get nào ở giữa). Thứ tự danh sách hiện tại (mới→cũ) là gì? Nếu Put(4,40) được gọi tiếp theo, phần tử nào bị loại?

Đáp án

Thứ tự (mới→cũ) sau ba lần Put là [3,2,1] — đúng như bài mô tả "đầu danh sách là phần tử mới dùng nhất". Vì chưa có Get nào can thiệp, phần tử 1 vẫn là ít dùng nhất (cuối danh sách). Khi Put(4,40) làm cache vượt sức chứa 3, phần tử 1 sẽ bị loại (node cuối = ít dùng nhất), thứ tự mới là [4,3,2].

Bài 2 (sửa lỗi). Đoạn mã Put sau có lỗi khiến bộ nhớ bị rò rỉ dần theo thời gian. Chỉ ra thiếu gì so với mẫu bài đã trình bày.

func (c *LRU) Put(key, val int) {
	if el, ok := c.items[key]; ok {
		el.Value.(*entry).val = val
		c.ll.MoveToFront(el)
		return
	}
	el := c.ll.PushFront(&entry{key, val})
	c.items[key] = el
	if c.ll.Len() > c.cap {
		last := c.ll.Back()
		c.ll.Remove(last)
	}
}
Đáp án

Thiếu dòng xóa mục tương ứng khỏi map khi loại bỏ node ít dùng nhất. Bài nhấn mạnh: "khi loại bỏ, phải xóa phần tử khỏi cả danh sách và map. Quên một trong hai gây rò rỉ bộ nhớ hoặc key 'ma' trỏ vào node đã xóa." Code trên chỉ gọi c.ll.Remove(last) (xóa khỏi list) nhưng không delete(c.items, ...) (xóa khỏi map), nên map sẽ giữ mãi một tham chiếu tới node đã bị xóa khỏi list — rò rỉ bộ nhớ và có thể gây hành vi sai khi tra cứu lại key đó sau này. Sửa:

if c.ll.Len() > c.cap {
	last := c.ll.Back()
	c.ll.Remove(last)
	delete(c.items, last.Value.(*entry).key)
}

Bài 3 (vận dụng thực tế). Dịch vụ của bạn cache kết quả gọi một API bên ngoài chậm (mỗi lần gọi mất 200ms), dùng LRU sức chứa 1000. Dựa vào đánh đổi nêu trong bài, giải thích tại sao LRU đáng dùng ở đây, kể cả với overhead bộ nhớ của node và map.

Đáp án

Bài nói rõ: "LRU đáng dùng khi giá trị được cache đắt để tính lại hoặc lấy về (query DB, gọi API), nơi tiết kiệm nhờ hit áp đảo overhead cấu trúc." Với API chậm 200ms mỗi lần, một cache hit tiết kiệm được 200ms, trong khi overhead bộ nhớ của một node danh sách (hai con trỏ) và một mục map chỉ tốn vài chục byte — chênh lệch giá trị giữa lợi ích (tránh gọi lại API) và chi phí (bộ nhớ cấu trúc phụ) cực kỳ lớn, nên LRU rất đáng dùng trong trường hợp này.

Bài 4 (bẫy/đánh đổi). Hai goroutine cùng gọi cache.Get(5) đồng thời trên bản LRU cài trong bài (không có khóa). Điều gì có thể xảy ra, và tại sao không thể chỉ dùng sync.RWMutex với RLock() cho hàm Get?

Đáp án

Vì code không có khóa, chạy đồng thời sẽ gây race condition trên cả map lẫn danh sách liên kết đôi (theo bài, "chạy với -race sẽ tố cáo ngay") — có thể dẫn tới panic hoặc dữ liệu cấu trúc bị hỏng (con trỏ sai, vòng lặp vô hạn trong list). Không thể dùng RLock() cho Get vì Get gọi MoveToFront, tức là nó ghi vào cấu trúc dữ liệu (thay đổi thứ tự danh sách) dù về mặt ngữ nghĩa trông như một thao tác đọc — bài nói rõ: "ngay cả Get cũng ghi... nên không thể dùng RWMutex với RLock cho Get — phải Lock đầy đủ". Cần bọc toàn bộ Get/Put bằng sync.Mutex (Lock đầy đủ, không phải RLock).

Bài 5 (đọc hiểu số liệu Go). Benchmark cho BenchmarkLRUPutGet 79.60 ns/op 64 B/op 2 allocs/op, và bài khẳng định "thời gian này không tăng theo số phần tử trong cache". Giải thích tại sao thao tác loại bỏ phần tử ít dùng nhất (khi cache đầy) không làm chậm thao tác này khi cache có 10 phần tử so với khi có 10 triệu phần tử.

Đáp án

Vì cả việc tra cứu key (qua map) lẫn việc cập nhật/loại bỏ node (qua container/list, dùng MoveToFront, PushFront, Back, Remove) đều là các thao tác O(1) không phụ thuộc vào số phần tử hiện có trong cấu trúc — map tra cứu bằng hash trực tiếp, còn danh sách liên kết đôi chỉ nối lại vài con trỏ tại đúng vị trí node cần thao tác (không cần quét qua các phần tử khác). Vì map lưu trực tiếp con trỏ tới node tương ứng, thao tác loại bỏ node cuối (Back()) hay đẩy node lên đầu (MoveToFront) không bao giờ cần duyệt qua các phần tử khác trong danh sách, nên chi phí như nhau bất kể cache có 10 hay 10 triệu phần tử.

Phần sau ta xét công cụ tìm bug tự động tích hợp sẵn trong Go: fuzzing native (từ Go 1.18) — cách go test -fuzz tự sinh đầu vào ngẫu nhiên tìm panic và ca biên, đo thật một bug được tìm ra.