1. Đánh giá việc dùng Firebase Realtime Database làm cache cho Firestore
Firebase Realtime Database (RTDB) có thể được sử dụng như một tầng cache cho Cloud Firestore, nhưng cần cân nhắc kỹ. RTDB là cơ sở dữ liệu NoSQL thời gian thực của Firebase, cho phép truy cập dữ liệu rất nhanh theo path (đường dẫn JSON). Trong khi đó, Firestore là cơ sở dữ liệu NoSQL dạng document với khả năng query mạnh mẽ hơn nhưng độ trễ có thể cao hơn. Thực tế cho thấy các thao tác ghi nhiều bản ghi trên Firestore có thể chậm hơn đáng kể so với RTDB (ví dụ: ghi 20 record trên Firestore mất ~2.4 giây, trong khi RTDB chỉ ~0.24 giây) . Do đó, sử dụng RTDB làm cache có thể cải thiện hiệu năng truy cập dữ liệu.
Tuy RTDB không có sẵn các tính năng cache như TTL (time-to-live) hoặc expire tự động cho bản ghi , bạn phải tự triển khai cơ chế xóa dữ liệu hết hạn. Việc này làm tăng độ phức tạp, nhưng không phải là không thể. Đã có những trường hợp sử dụng RTDB như một tầng cache trung gian trong hệ thống thực tế – chẳng hạn một connector của Google đã dùng Firebase Realtime Database kết hợp với cache khác để giảm tần suất truy xuất dữ liệu gốc .
So với các giải pháp cache chuyên dụng (như Redis, Memcached), dùng RTDB làm cache ít phổ biến hơn. Ưu điểm là RTDB nằm trong hệ sinh thái Firebase, dễ tích hợp và có khả năng đồng bộ thời gian thực tới nhiều client nếu cần. Nếu tất cả thao tác đọc/ghi đều thông qua backend (service account) thì vấn đề bảo mật và phân quyền cũng đơn giản (backend có quyền admin). Nhược điểm là phải quản lý thêm một cơ sở dữ liệu thứ hai song song, đảm bảo dữ liệu giữa cache (RTDB) và Firestore luôn nhất quán. Tuy nhiên, nếu triển khai đúng (theo mô hình write-through/read-through), ta có thể đạt tỷ lệ cache hit rất cao (đã có báo cáo đạt 95–98% cache hit) giúp giảm đáng kể số lượt đọc Firestore và chi phí liên quan .
Tóm lại, Firebase RTDB có thể dùng làm cache cho Firestore nếu bạn chấp nhận tự xây dựng các cơ chế bổ sung (TTL, lock, đồng bộ). Một số dự án đã áp dụng kỹ thuật cache tương tự (dùng kho dữ liệu nhanh hơn làm cache trước Firestore) và thu được hiệu quả tốt về chi phí và hiệu năng . Không có nhiều tài liệu chính thức về việc dùng RTDB làm cache, nhưng nguyên tắc tương tự các giải pháp cache khác vẫn áp dụng được.
2. Đề xuất cơ chế caching (write-through, read-through, TTL, lock) với Realtime Database
Để đảm bảo hiệu năng cao và tính nhất quán giữa cache (RTDB) và Firestore, ta có thể thiết kế một cơ chế cache với các thành phần sau:
- Read-through cache (đọc xuyên qua cache): Khi backend cần đọc dữ liệu (ví dụ profile người dùng), nó sẽ kiểm tra cache trước.
- Nếu dữ liệu đã có trong RTDB và còn hiệu lực (chưa hết TTL), trả về ngay từ cache (nhanh, không tốn lượt đọc Firestore).
- Nếu không có hoặc đã hết hạn, backend sẽ đọc từ Firestore rồi sau đó ghi vào cache (RTDB) để các lần truy cập sau nhanh hơn. Cơ chế này đảm bảo mọi lần đọc đều “đi qua” cache: cache hit thì dùng, cache miss thì nạp dữ liệu vào cache.
- Write-through cache (ghi xuyên thẳng xuống nguồn): Mọi thao tác ghi/update dữ liệu sẽ đi qua cache và đồng bộ tức thì tới Firestore. Cụ thể, khi cần ghi dữ liệu (ví dụ cập nhật profile hoặc tạo transaction):
- Ghi vào Firestore trước để đảm bảo dữ liệu chính thống được lưu (tránh tình trạng ghi vào cache xong mà chưa kịp ghi Firestore đã xảy ra lỗi).
- Nếu Firestore ghi thành công, cập nhật cache (RTDB) tương ứng với cùng dữ liệu đó. Việc cập nhật cache ngay sau khi ghi Firestore giữ cho cache luôn phản ánh trạng thái mới nhất của dữ liệu. (Ngược lại, nếu ghi Firestore thất bại, ta không cập nhật cache, hoặc xóa mục cache cũ nếu cần, để tránh bất nhất). Với cách làm này, toàn bộ thao tác đọc/ghi đều đi qua tầng cache, giúp ta kiểm soát được lúc nào cache hợp lệ. Nhờ đó, vấn đề invalidation (phế bỏ cache cũ) được đơn giản hóa, vì khi mọi đọc/ghi đều thông qua một chỗ, ta biết chính xác khi nào dữ liệu cache không còn đúng .
- TTL cho mỗi key: Mỗi mục cache sẽ có một thời gian sống (TTL) nhất định, ví dụ mặc định 5 phút (có thể cấu hình tùy loại dữ liệu). TTL được lưu kèm với dữ liệu trong RTDB. Khi đọc cache, backend kiểm tra timestamp hiện tại so với TTL:
- Nếu đã quá hạn, coi như cache hết hiệu lực (cache miss) và cần lấy mới từ Firestore. Có thể xóa hoặc cập nhật lại mục đó trong cache.
- TTL tùy chỉnh cho phép, ví dụ: thông tin profile ít thay đổi có thể TTL dài hơn (vài phút), còn dữ liệu giao dịch nhạy cảm có TTL ngắn hơn để đảm bảo tươi mới. Do Firebase RTDB không tự xóa dữ liệu hết hạn, ta phải chủ động xóa. Cách đơn giản là xóa trong lần đọc nếu phát hiện hết TTL, hoặc có thể chạy một routine định kỳ quét các key hết hạn rồi xóa.
- Cơ chế khóa (lock/unlock) theo key hoặc path: Để tránh race condition khi nhiều luồng cùng đọc-ghi một bản ghi, ta triển khai khóa trên RTDB:
- Mỗi record (hoặc nhóm dữ liệu theo path) có thể gắn một đối tượng khóa trong một nhánh riêng (ví dụ /locks/<path>). Khi một tiến trình muốn cập nhật dữ liệu đó, nó phải đặt khóa (lock) trước: ghi một marker vào path khóa nếu chưa có ai khóa. Nếu khóa đã tồn tại, tiến trình khác phải đợi (hoặc từ chối thao tác) cho tới khi mở khóa (unlock).
- Sau khi khóa thành công, tiến trình đọc/ghi Firestore và cập nhật cache như bình thường, rồi mở khóa (xóa marker khóa) khi xong. Cơ chế này đảm bảo trong một khoảng thời gian, chỉ một tiến trình thao tác trên một dữ liệu nhất định, tránh ghi đè lẫn nhau.
- Để triển khai, ta có thể dùng Firebase RTDB transaction hoặc các phương thức điều kiện. RTDB cho phép thực hiện cập nhật có điều kiện một cách nguyên tử. Ví dụ, ta gọi hàm Transaction() trên node khóa: nếu node đó đang null (chưa khóa) thì ghi thông tin khóa vào, ngược lại thì báo lỗi để biết đang bị khóa. Giao dịch này được đảm bảo atomic và sẽ tự lặp lại nếu có xung đột .
- Nên gắn kèm một expiresAt cho khóa (TTL cho khóa, ví dụ vài giây) để nếu chẳng may tiến trình quên mở khóa (hoặc bị chết), khóa không bị kẹt vĩnh viễn. Khi có TTL cho khóa, tiến trình khác thấy khóa hết hạn có thể ghi đè khóa mới. (Điều này cần cẩn thận để không hai bên cùng quyết định đè khóa; thường thì một bên sẽ thắng trong transaction trước).
Tóm tắt cơ chế hoạt động: Mỗi request đọc/ghi từ ứng dụng backend đều truy cập vào RTDB trước. Cache RTDB đóng vai trò như bộ đệm tạm thời lưu dữ liệu hay dùng (profile, session, transaction, v.v.). Firestore là nguồn dữ liệu chính để đảm bảo tính bền vững và nhất quán dài hạn. Việc đồng bộ hai bên do backend đảm nhiệm: mọi thay đổi được ghi ngay xuống Firestore và cache, mọi lần đọc đều kiểm tra cache và chỉ truy vấn Firestore khi cần. Cách làm này tương tự việc dùng Redis làm cache cho cơ sở dữ liệu, chỉ khác là ta dùng dịch vụ Firebase có sẵn. Nếu được thiết kế tốt, nó sẽ kết hợp được ưu điểm của cả hai: đọc/ghi nhanh của RTDB và tính nhất quán, lưu trữ lâu dài của Firestore.
3. Triển khai MVP bằng Golang với Firebase (Realtime DB + Firestore)
Phần này hướng dẫn cách xây dựng một MVP (minimum viable product) bằng Golang đáp ứng các chức năng yêu cầu: Set cache với TTL, Get cache (read-through), Write cache (write-through) và Lock/Unlock theo key/path.
3.1 Chuẩn bị và cấu hình Firebase
Trước tiên, cần một Firebase project đã bật Firestore và Realtime Database. Thực hiện các bước sau:
- Tạo Firebase Project và cơ sở dữ liệu: Vào Firebase Console, tạo project (nếu chưa có). Bật Cloud Firestore (ở chế độ production hoặc test tùy nhu cầu) và bật Realtime Database (chọn vị trí và chế độ quyền truy cập phù hợp, tạm thời có thể để test mode cho phép đọc/ghi tự do để thử nghiệm).
- Tải tệp credentials (service account): Để backend Golang truy cập Firebase, bạn sử dụng Firebase Admin SDK. Truy cập Project Settings -> Service Accounts trên Firebase Console, tạo một service account key dạng JSON. File JSON này chứa thông tin xác thực mà server sẽ dùng để đăng nhập và có quyền truy cập DB. Lưu tệp JSON này vào máy chủ của bạn.
- Thiết lập biến môi trường hoặc cấu hình: Đảm bảo Golang app biết đường dẫn tới tệp JSON. Cách đơn giản: thiết lập biến môi trường GOOGLE_APPLICATION_CREDENTIALS=<path/to/serviceAccount.json>, hoặc trong code bạn sẽ chỉ định tường minh bằng option.WithCredentialsFile.
- URL của Realtime Database: Lấy URL của RTDB từ Firebase console (thường có dạng https://<YOUR_PROJECT_ID>.firebaseio.com). URL này sẽ dùng khi khởi tạo client RTDB.
Tiếp theo, trong code Golang, sử dụng Firebase Admin SDK để kết nối tới Firestore và RTDB. Bạn cần import các package cần thiết và khởi tạo app như ví dụ dưới đây:
import (
"context"
"log"
firebase "firebase.google.com/go"
"firebase.google.com/go/db" // Realtime Database
"cloud.google.com/go/firestore" // Firestore client
"google.golang.org/api/option"
)
func initFirebaseApp() (*db.Client, *firestore.Client, error) {
ctx := context.Background()
// Thay đường dẫn bằng đường dẫn tới file service account JSON của bạn:
opt := option.WithCredentialsFile("path/to/serviceAccountKey.json")
// Cung cấp URL Realtime DB và project ID trong cấu hình
config := &firebase.Config{
DatabaseURL: "https://<YOUR_PROJECT_ID>.firebaseio.com",
ProjectID: "<YOUR_PROJECT_ID>",
}
// Khởi tạo ứng dụng Firebase
app, err := firebase.NewApp(ctx, config, opt)
if err != nil {
return nil, nil, err
}
// Tạo client cho Realtime Database
rtdbClient, err := app.Database(ctx)
if err != nil {
return nil, nil, err
}
// Tạo client cho Firestore
fsClient, err := app.Firestore(ctx)
if err != nil {
return nil, nil, err
}
return rtdbClient, fsClient, nil
}
Ở đoạn code trên, ta khởi tạo app Firebase với DatabaseURL và ProjectID. Sau đó dùng app.Database() để lấy client kết nối Realtime DB và app.Firestore() để lấy client Firestore. (Lưu ý: Bạn cũng có thể dùng firebase.NewApp(ctx, nil, opt) rồi app.DatabaseWithURL(ctx, url) như tài liệu Firebase hướng dẫn – hiệu quả tương đương).
3.2 Triển khai các chức năng caching trong Golang
Sau khi có các client, ta triển khai các hàm chính: Set cache, Get cache (read-through), Write (write-through), Lock và Unlock. Trước hết, xác định cách lưu dữ liệu trong RTDB. Ta có thể lưu mỗi mục cache dưới dạng một JSON gồm giá trị và metadata TTL, ví dụ:
// Cấu trúc JSON lưu tại path cache, ví dụ: /cache/users/123
{
"value": { ... dữ liệu thực tế ... },
"expiresAt": 1684000000 // timestamp Unix giây
}
Trong Golang, có thể định nghĩa một struct cho cache entry:
type CacheEntry[T any] struct {
Value T `json:"value"`
ExpiresAt int64 `json:"expiresAt"`
}
Ta dùng generic T cho Value để có thể tái sử dụng cho nhiều loại dữ liệu (profile, session,…). Trong code thực tế, bạn có thể định nghĩa cụ thể hoặc dùng interface{} nếu không tiện dùng generic.
Hàm SetCache với TTL: Hàm này ghi dữ liệu vào RTDB dưới path cache tương ứng, kèm TTL. Nếu đã có sẵn TTL truyền vào, sử dụng nó, nếu không dùng mặc định (ví dụ 5 phút). Triển khai như sau:
// SetCache lưu dữ liệu value vào cache tại path với TTL (giây)
func SetCache[T any](ctx context.Context, rtdb *db.Client, path string, value T, ttlSeconds int64) error {
expires := time.Now().Unix() + ttlSeconds
entry := CacheEntry[T]{ Value: value, ExpiresAt: expires }
ref := rtdb.NewRef(path)
// Ghi đè (hoặc tạo mới) entry vào vị trí cache
if err := ref.Set(ctx, entry); err != nil {
return err
}
return nil
}
Trong đó, path có thể là dạng “/cache/users/<userID>” hoặc bất kỳ key nào bạn muốn cache. Ta tạo một ref tới path đó và gọi Set để lưu toàn bộ đối tượng (sẽ được JSON hóa). Lưu ý: ttlSeconds có thể truyền vào, ví dụ 300 (5 phút), hoặc bạn có thể có logic chọn TTL tùy loại dữ liệu (có thể thêm tham số hoặc hàm quá tải cho từng kiểu).
Hàm GetCache (cơ chế read-through): Hàm này sẽ kiểm tra trong cache, nếu hợp lệ thì trả về, nếu không thì lấy từ Firestore và cập nhật vào cache. Giả sử ta biết kiểu dữ liệu cần lấy (ví dụ UserProfile), ta làm như sau:
// GetCache đọc dữ liệu kiểu T từ cache. Nếu cache miss hoặc hết hạn, lấy từ Firestore bằng hàm fetchFn.
func GetCache[T any](ctx context.Context, rtdb *db.Client, cachePath string, fetchFn func() (T, error)) (T, error) {
var entry CacheEntry[T]
ref := rtdb.NewRef(cachePath)
err := ref.Get(ctx, &entry)
var zero T
if err != nil {
// Lỗi khi get từ cache (ví dụ không tồn tại hoặc network). Xử lý cache miss giống như cache trống.
entry.ExpiresAt = 0
}
if entry.ExpiresAt > time.Now().Unix() && entry.Value != nil {
// Cache còn hiệu lực
return entry.Value, nil
}
// Nếu cache hết hạn hoặc không có, ta đọc từ nguồn Firestore
freshValue, fetchErr := fetchFn()
if fetchErr != nil {
return zero, fetchErr
}
// Cập nhật lại cache với giá trị mới và TTL mặc định (ví dụ 5 phút)
ttl := int64(300)
_ = SetCache(ctx, rtdb, cachePath, freshValue, ttl) // bỏ qua lỗi cache để không chặn trả về dữ liệu
return freshValue, nil
}
Hàm GetCache ở trên nhận một hàm fetchFn – đây là chức năng để lấy dữ liệu từ Firestore khi cache miss. Cách sử dụng: khi gọi GetCache, ta truyền vào một lambda hoặc hàm truy vấn Firestore. Ví dụ:
user, err := GetCache(ctx, rtdbClient, "/cache/users/"+userID, func() (UserProfile, error) {
// fetch from Firestore
docSnap, err := fsClient.Collection("users").Doc(userID).Get(ctx)
if err != nil {
return UserProfile{}, err
}
var profile UserProfile
if err := docSnap.DataTo(&profile); err != nil {
return UserProfile{}, err
}
return profile, nil
})
Trong ví dụ trên, nếu cache có dữ liệu và chưa hết hạn, ta sẽ không chạy hàm fetchFn (tiết kiệm một lượt đọc Firestore). Nếu cache hết hạn hoặc miss, hàm fetchFn sẽ được gọi để lấy UserProfile từ Firestore, sau đó cập nhật cache để lần sau nhanh hơn. Việc cập nhật cache được thực hiện bất đồng bộ (ta cố gắng set nhưng không để lỗi cache chặn trả về dữ liệu thật cho người dùng).
Lưu ý: Ở bước kiểm tra cache, nếu ref.Get trả về lỗi do node không tồn tại, ta thiết lập entry.ExpiresAt = 0 để coi như cache miss. Nếu entry.ExpiresAt tồn tại nhưng entry.Value rỗng (nil) do một lý do nào đó, ta cũng coi như miss.
Hàm WriteThrough (ghi cache và Firestore): Ta cài đặt hàm ghi sao cho nhất quán: ghi Firestore trước, sau đó cache. Ví dụ dưới đây minh họa cập nhật profile người dùng:
// WriteThrough ghi dữ liệu T vào Firestore và cập nhật cache
func WriteThrough[T any](ctx context.Context, rtdb *db.Client, fs *firestore.Client, cachePath string, fsDoc *firestore.DocumentRef, data T, ttlSeconds int64) error {
// Ghi vào Firestore trước
_, err := fsDoc.Set(ctx, data)
if err != nil {
return err
}
// Nếu ghi Firestore thành công, cập nhật cache (write-through)
cacheErr := SetCache(ctx, rtdb, cachePath, data, ttlSeconds)
if cacheErr != nil {
// Nếu cache ghi lỗi, vẫn trả về thành công vì Firestore đã có dữ liệu
// (Có thể log warning để xử lý sau). Ta không nên return err ở đây
// để tránh báo lỗi cho client dù dữ liệu đã lưu thành công.
log.Println("Warning: cache update failed:", cacheErr)
}
return nil
}
Cách dùng hàm này: Ví dụ muốn cập nhật UserProfile của userID:
userProfile := UserProfile{ Name: "New Name", ... }
cachePath := "/cache/users/" + userID
docRef := fsClient.Collection("users").Doc(userID)
err := WriteThrough(ctx, rtdbClient, fsClient, cachePath, docRef, userProfile, 300)
if err != nil {
// Xử lý lỗi (nếu Firestore ghi thất bại)
}
Sau khi WriteThrough thực hiện, Firestore chắc chắn đã có dữ liệu mới. Cache cũng được đặt lại với TTL mới (5 phút). Như vậy các lần đọc kế tiếp sẽ thấy dữ liệu mới từ cache. Nhờ cơ chế này, cache luôn được làm mới ngay sau mỗi lần ghi nên tính nhất quán được đảm bảo.
Hàm Lock và Unlock: Để quản lý khóa, ta dùng một path riêng, ví dụ /locks/<key>. Key ở đây có thể là cùng tên với key dữ liệu (hoặc một tên đại diện cho một nhóm). Dữ liệu khóa có thể chỉ là một timestamp hoặc thông tin về chủ sở hữu khóa. Ta triển khai Lock bằng transaction để đảm bảo tính nguyên tử: chỉ tạo khóa nếu chưa có.
// Lock attempts to acquire a lock at lockPath. ttlSeconds is optional lock expiry.
func Lock(ctx context.Context, rtdb *db.Client, lockPath string, ttlSeconds int64) error {
ref := rtdb.NewRef(lockPath)
// Sử dụng Transaction để đảm bảo set khóa nguyên tử
err := ref.Transaction(ctx, func(current interface{}) (interface{}, error) {
if current == nil {
// Chưa có ai giữ khóa, tiến hành đặt khóa
lockInfo := map[string]interface{}{
"lockedAt": time.Now().Unix(),
}
if ttlSeconds > 0 {
lockInfo["expiresAt"] = time.Now().Unix() + ttlSeconds
}
return lockInfo, nil // giá trị mới của node khóa
} else {
// Đã có khóa tồn tại
// Kiểm tra nếu có expiresAt và đã hết hạn thì cho phép giành khóa (optional)
m := current.(map[string]interface{})
if exp, ok := m["expiresAt"].(float64); ok && int64(exp) < time.Now().Unix() {
// Khóa hiện tại hết hạn, chiếm khóa
lockInfo := map[string]interface{}{
"lockedAt": time.Now().Unix(),
}
if ttlSeconds > 0 {
lockInfo["expiresAt"] = time.Now().Unix() + ttlSeconds
}
return lockInfo, nil
}
// Nếu chưa hết hạn, không thể khóa
return nil, fmt.Errorf("locked")
}
})
if err != nil {
if strings.Contains(err.Error(), "locked") {
return fmt.Errorf("cannot acquire lock, already locked")
}
return err // lỗi khác (network, v.v.)
}
return nil // khóa thành công
}
Hàm trên sẽ cố gắng đặt khóa. Trong phần Transaction, nếu current == nil nghĩa là node /locks/… chưa có dữ liệu -> đặt một map lockInfo với trường lockedAt (thời điểm khóa) và expiresAt (nếu có TTL cho khóa). Nếu current không nil, nghĩa là đã có khóa:
- Ta có thể kiểm tra xem khóa đó có expiresAt và đã quá hạn chưa. Nếu khóa hết hạn, cho phép đặt lại (giành khóa).
- Nếu còn hiệu lực, trả lỗi “locked” để transaction không commit thay đổi.
Sau khi transaction, nếu err chứa “locked” tức là có người đang giữ khóa hợp lệ, ta báo lỗi không lấy được khóa. Nếu err == nil, tức đã khóa thành công.
Hàm Unlock: Mở khóa đơn giản là xóa node khóa:
func Unlock(ctx context.Context, rtdb *db.Client, lockPath string) error {
ref := rtdb.NewRef(lockPath)
if err := ref.Delete(ctx); err != nil {
return err
}
return nil
}
Gọi Unlock sau khi thao tác xong (ví dụ sau khi đã ghi Firestore và cache). Nên đảm bảo Unlock được thực thi kể cả khi có lỗi ở quá trình chính (có thể dùng defer ngay sau khi Lock thành công để luôn mở khóa khi hàm kết thúc).
Sử dụng lock trong quy trình: Giả sử ta muốn cập nhật số dư tài khoản (transaction) của user sao cho không bị race condition khi nhiều nơi cùng cộng/trừ:
lockPath := "/locks/users/" + userID
if err := Lock(ctx, rtdbClient, lockPath, 10); err != nil {
return fmt.Errorf("resource busy, try again later")
}
defer Unlock(ctx, rtdbClient, lockPath)
// Đọc số dư hiện tại (có thể dùng GetCache hoặc trực tiếp Firestore nếu muốn chắc chắn dữ liệu mới nhất)
// ... tính toán số dư mới ...
// Ghi số dư mới
err := WriteThrough(ctx, rtdbClient, fsClient, "/cache/users/"+userID+"/balance", docRef, newBalance, 300)
Với cách này, trong khoảng thời gian khóa (tối đa 10 giây theo TTL của khóa ở trên), chỉ một luồng xử lý số dư cho user đó. Các luồng khác sẽ nhận được lỗi “busy” và có thể thử lại sau. TTL khóa 10 giây đảm bảo nếu luồng này gặp sự cố trước khi Unlock, khóa sẽ tự coi như hết hiệu lực sau 10 giây, tránh kẹt vĩnh viễn.
3.3 Kiểm tra và cấu hình bổ sung
Với MVP trên, bạn đã có các hàm cơ bản để thực hiện caching và đồng bộ giữa RTDB và Firestore. Khi chạy thử nghiệm, hãy chú ý một số điểm cấu hình Firebase sau để hệ thống hoạt động trơn tru:
- Quyền truy cập Realtime Database: Nếu dùng Admin SDK (service account) như trên thì bypass rule, không cần đổi luật truy cập. Tuy nhiên, nếu dùng chế độ không qua admin, cần đặt rules cho RTDB cho phép read/write tại các path /cache/… và /locks/… phù hợp. Trong giai đoạn MVP, có thể để luật mở (public) cho đơn giản, vì backend đã an toàn.
- Chỉ số (Index) trong Firestore: Các thao tác trên Firestore ở trên chủ yếu là get/set theo document ID nên không cần index đặc biệt. Nếu bạn dùng fetchFn phức tạp (ví dụ query theo field), hãy đảm bảo field đó đã được index (Firestore sẽ báo trong console nếu thiếu).
- Dọn dẹp dữ liệu cache định kỳ: Mặc dù TTL giúp hạn chế sử dụng dữ liệu cũ, bạn có thể triển khai một routine (ví dụ một Goroutine chạy ngầm hoặc một cron job) mỗi vài phút quét các nút /cache và xóa những mục đã hết hạn để giảm tải dung lượng RTDB. Việc này có thể thực hiện bằng cách dùng OrderByChild(“expiresAt”) kết hợp EndAt(<now>) để lấy các mục hết hạn rồi xóa chúng.
- Đảm bảo đồng bộ hai chiều (nếu cần): Trong kiến trúc hiện tại, ta giả định mọi thay đổi dữ liệu đều đi qua backend này. Nếu có luồng khác thay đổi Firestore trực tiếp (ví dụ một admin tool không qua cache), cache có thể bị lạc hậu. Để khắc phục, có thể dùng Cloud Functions lắng nghe Firestore để cập nhật cache, hoặc cho backend đăng ký listener Firestore (khá phức tạp, Firestore server SDK không hỗ trợ realtime update dễ dàng). Phương án đơn giản: giảm TTL để dữ liệu tự hết hạn sớm, hoặc thiết kế để mọi thay đổi đều phải qua tầng backend cache này.
Cuối cùng, sau khi cấu hình và chạy các hàm trên, bạn có thể thử nghiệm: Tạo một document trên Firestore, sau đó dùng hàm GetCache để lấy – lần đầu sẽ thấy đọc từ Firestore, các lần sau trong vòng TTL sẽ lấy từ RTDB nhanh hơn. Thử cập nhật document bằng WriteThrough và đảm bảo ngay sau đó GetCache trả về dữ liệu mới (cache đã được update). Kiểm tra trường hợp concurrent bằng cách giả lập hai tiến trình cùng Lock cùng key để xem cơ chế khóa hoạt động.
4. Kết luận
Việc sử dụng Firebase Realtime Database làm database cache cho Firestore là khả thi và có thể mang lại hiệu suất cao nếu được triển khai đúng cách. Bạn sẽ cần đầu tư xây dựng cơ chế TTL và khóa thủ công (do Firebase chưa hỗ trợ sẵn) , nhưng đổi lại có thể giảm tải đáng kể cho Firestore (cache hit cao giúp giảm tới hàng chục lần lượt đọc Firestore ). Giải pháp đề xuất ở trên kết hợp read-through và write-through caching – mọi thao tác đều thông qua cache, giúp dữ liệu giữa cache và nguồn đồng bộ chặt chẽ . Mặc dù phức tạp hơn việc dùng một cơ sở dữ liệu duy nhất, cách làm này phù hợp khi ứng dụng đòi hỏi độ trễ thấp và tần suất truy cập cao đối với một số dữ liệu trọng điểm (như hồ sơ người dùng, phiên làm việc, dữ liệu giao dịch tạm thời,…).
Bằng cách triển khai MVP trên Golang với Firebase Admin SDK, bạn có thể kiểm chứng tính đúng đắn của cơ chế cache này. Khi mở rộng ra môi trường thực tế, hãy theo dõi sát hiệu quả cache (tỷ lệ cache hit, thời gian phản hồi) và điều chỉnh TTL cũng như phạm vi dữ liệu cache cho phù hợp. Nếu hệ thống phát sinh độ trễ ở tầng cache (RTDB) do quá nhiều truy cập, có thể cân nhắc dùng thêm cache trong RAM của máy chủ (two-level cache) hoặc tối ưu cấu trúc dữ liệu lưu trong RTDB.
Nhìn chung, với thiết kế cẩn thận, Firebase Realtime Database có thể đóng vai trò một bộ đệm cải thiện hiệu năng cho Firestore, đồng thời giữ được trải nghiệm realtime nhất quán cho ứng dụng của bạn.
Tài liệu tham khảo:
- Firebase Admin SDK (Golang) – Hướng dẫn kết nối Realtime Database và Firestore https://medium.com/@vubon.roy/lets-integrate-the-firebase-realtime-database-with-golang-7c065a7b7313#:~:text=opt%20%3A%3D%20option.WithCredentialsFile%28home%20%2B%20,v%22%2C%20err
- Kinh nghiệm caching Firestore bằng lớp trung gian (Redis) – Simple Cached Firestore https://medium.com/weekly-webtips/simplified-firestore-with-redis-3dc54cdc3ce9#:~:text=As%20mentioned%20above%2C%20a%20single,to%20a%20specific%20Firestore%20collection
- So sánh hiệu năng Firestore vs Realtime Database https://www.reddit.com/r/Firebase/comments/g49qfy/firestore_write_performance_10x_slower_than/#:~:text=Then%20I%20converted%20this%20line,to%20use%20the%20Realtime%20database
- Cơ chế TTL và nhu cầu tự triển khai trong Realtime Database https://stackoverflow.com/questions/38447748/firebase-realtime-database-entries-expiration#:~:text=6
- Ví dụ Google sử dụng Realtime Database làm cache trong ứng dụng thực tế https://developers.google.com/looker-studio/connector/firebase-cache#:~:text=The%20Chrome%20UX%20Connector%20facilitates,See%20code%20for%20implementation%20details