Fuzzy search trên cột phone_number đã mã hoá
Trigram Blind Index, Exact Blind Index, FPE — trade-off giữa searchability và security khi cột DB được encrypt.
Câu hỏi
Cột
phone_numbertrong DB được mã hoá để tránh lộ dữ liệu. Bây giờ có yêu cầu cho phép tìm kiếm fuzzy search (kiểuLIKE '%9123%') trên cột đó — làm thế nào?
Dành cho level
Senior / Staff — câu này test khả năng thiết kế trade-off giữa security và searchability, không phải chỉ biết "dùng AES encrypt".
Cốt lõi cần nhớ
- Encrypted ciphertext hoàn toàn random →
LIKEtrên ciphertext vô nghĩa, không thể match. - Giải pháp production-grade là Trigram Blind Index: tách phone thành các chuỗi 3 ký tự → HMAC từng chuỗi → lưu hash vào bảng index riêng → search qua hash, decrypt để verify kết quả.
- Mọi phương án đều có leakage (rò rỉ thông tin một phần) — điểm quan trọng interviewer hay hỏi là bạn aware điều này và biết cách giảm thiểu.
Câu trả lời mẫu
Vấn đề cốt lõi là encryption biến phone number thành một chuỗi random hoàn toàn — LIKE '%9123%' trên ciphertext sẽ không match gì cả. Có vài hướng tiếp cận tùy theo yêu cầu bảo mật và performance.
Với fuzzy search thực sự, tôi sẽ dùng Trigram Blind Index: tách phone gốc thành các chuỗi 3 ký tự liên tiếp (trigram), HMAC từng trigram với một secret key riêng, rồi lưu hash vào bảng index phụ. Khi user tìm "9123", hệ thống cũng tách thành trigrams, hash chúng, tìm trong bảng index — DB chỉ thấy hash vô nghĩa, không biết đang tìm cái gì, nhưng vẫn match được. Sau đó decrypt các row candidate để verify chính xác.
Cách này cho phép substring search mà không expose plaintext trong DB, nhưng vẫn có frequency leakage — attacker biết những row nào chia sẻ trigram giống nhau. Để giảm thiểu, ta thêm per-column salt vào HMAC để trigram của cột phone khác với trigram của cột khác dù giá trị giống nhau.
Nếu chỉ cần exact match (tìm đúng số điện thoại), dùng Exact Blind Index là đủ — hash toàn bộ số đã chuẩn hóa, lưu riêng một cột, index cột đó. Đơn giản hơn và leakage ít hơn nhiều.
Phân tích chi tiết
Tại sao LIKE không hoạt động trên ciphertext
AES không giữ lại bất kỳ pattern nào của plaintext — đó chính là mục đích của encryption.
Exact Blind Index — tìm chính xác
Dùng khi chỉ cần tìm đúng một số điện thoại (login bằng phone, dedup, v.v).
Ý tưởng: hash toàn bộ phone → lưu hash → search bằng hash.
Flow INSERT:
Flow SEARCH:
Tại sao chỉ exact, không fuzzy được?
Hash của cả chuỗi và hash của substring không có liên quan gì nhau — không thể biết cái này là "con" của cái kia. Đó là tính chất cơ bản của hash function.
Trigram Blind Index — fuzzy search
Bước 1 — Trigram là gì?
Tách chuỗi thành các đoạn 3 ký tự liên tiếp:
Khi user tìm "9123", cũng tách tương tự:
Nếu "912" và "123" đều có trong phone 0912345678 → khả năng cao là match.
Bước 2 — Tại sao phải hash trigram (phần "Blind")?
Nếu lưu trigram thẳng vào DB:
Mục đích encrypt ban đầu bị phá vỡ hoàn toàn. Vì vậy phải hash từng trigram:
DB bị "mù" — nó thực hiện được việc tìm kiếm nhưng không biết đang tìm cái gì. Đó là lý do gọi là Blind Index.
Flow INSERT:
Flow SEARCH với query "9123":
Tại sao cần bước verify ở cuối? Vì có thể có false positive — phone 0991230000 cũng chứa trigram "912" và "123" nhưng không theo thứ tự đúng. Decrypt + verify loại bỏ chúng.
Schema và code:
Pagination với Trigram Blind Index
Đây là điểm phức tạp thường bị bỏ qua. Ví dụ: lấy 10 user có phone LIKE '123' và name LIKE 'nam', sắp xếp theo created_at.
Nếu name không encrypted — tương đối đơn giản:
Sau đó decrypt phone_enc và verify. Sort theo created_at hoạt động bình thường vì cột này không encrypted.
Vấn đề: offset-based pagination bị vỡ do false positives
Số lượng false positives không đoán trước được → OFFSET không đáng tin cậy.
Giải pháp — Cursor-based pagination:
Thay vì OFFSET, dùng created_at của item cuối làm cursor:
Fetch nhiều hơn cần (50 thay vì 10) để bù cho false positives bị loại sau verify.
Tóm tắt pagination:
| Yêu cầu | Làm được không |
|---|---|
| Filter phone LIKE + name LIKE | ✅ Được |
Sort theo created_at | ✅ Dễ (không encrypted) |
| Offset-based pagination | ⚠️ Không đáng tin — false positives làm lệch count |
| Cursor-based pagination | ✅ Recommended |
Total count chính xác | ❌ Khó — phải decrypt hết để đếm |
Luồng tổng thể
So sánh các phương án
| Phương án | Fuzzy Search | Performance | Security | Độ phức tạp |
|---|---|---|---|---|
| Trigram Blind Index | Substring đầy đủ | O(log n) via index | Tốt (có leakage) | Cao |
| Exact Blind Index | Chỉ exact | O(log n) | Tốt nhất | Thấp |
| FPE | Prefix (hạn chế) | O(log n) | Trung bình | Trung bình |
| Decrypt-all | Full text | O(n) — không scale | Tốt | Thấp |
Security considerations
Trigram Leakage
Trigram Blind Index lộ thông tin tần suất: attacker biết hai user có cùng trigram "912". Với phone numbers, tần suất trigram có thể dùng để frequency analysis.
Giảm thiểu:
- Per-column salt trong HMAC — trigram "912" của cột
phonekhác với cột khác. - Padding noise — thêm dummy trigrams vào index (tăng false positives, giảm precision).
- Rate limiting trên search API — giới hạn số lần search.
- Audit log mọi search query.
Key Management
Secret keys (AES key + HMAC trigram key) phải được lưu trong AWS Secrets Manager hoặc HashiCorp Vault, không bao giờ hardcode hay lưu trong DB.
Bẫy thường gặp
❌ "Decrypt toàn bộ rồi filter ở application" → Tại sao sai: O(n) decrypt, không scale với triệu rows, gây timeout và memory spike. ✅ Đúng hơn: Cần index-based approach để search O(log n).
❌ "Dùng LIKE trực tiếp trên cột encrypted"
→ Tại sao sai: AES ciphertext là random bytes — LIKE '%9123%' sẽ không bao giờ match.
✅ Đúng hơn: Phải build searchable index từ plaintext trước khi encrypt.
❌ "Trigram Blind Index hoàn toàn bảo mật, không lộ gì" → Tại sao sai: Vẫn có frequency leakage — attacker có thể biết hai row chia sẻ trigram giống nhau. ✅ Đúng hơn: Blind index leaks set-membership — cần per-column salt và rate limiting để giảm thiểu.
❌ "Hash phone rồi LIKE trên hash"
→ Tại sao sai: Hash của "0912345678" và hash của "9123" hoàn toàn khác nhau — không có quan hệ gì. LIKE trên hash vô nghĩa.
✅ Đúng hơn: Phải hash từng trigram riêng lẻ, không hash toàn bộ string.
❌ "Dùng OFFSET bình thường để phân trang"
→ Tại sao sai: False positives từ trigram matching làm lệch số lượng kết quả sau verify — page 2 có thể trả về ít hơn expected.
✅ Đúng hơn: Dùng cursor-based pagination (dùng created_at làm cursor) và over-fetch để bù false positives.
Câu hỏi follow-up
1. Index bị bloat khi data lớn, làm sao optimize?
Dùng Bloom Filter thay vì bảng index riêng: encode tất cả trigram hashes vào 1 Bloom Filter bitset, lưu thành 1 cột BYTEA trên bảng users. False positive cao hơn một chút nhưng storage nhỏ hơn nhiều — thay vì 500M rows (50M users × 10 trigrams) chỉ còn 50M rows với 1 cột bytes. Sau khi Bloom filter qua, decrypt verify chính xác như bình thường.
2. Nếu phone number thay đổi, update index thế nào?
Xoá toàn bộ trigram cũ (DELETE FROM phone_trigram_index WHERE user_id = ?), tính lại trigrams mới, insert lại. Wrap toàn bộ trong transaction cùng với update phone_enc để tránh trạng thái inconsistent.
3. Performance với 50 triệu rows?
Index phone_trigram_index(trigram_hash) xử lý tốt — lookup O(log n). Vấn đề là bảng index phình to (50M phones × 10 trigrams = 500M rows). Giải pháp: partition bảng index theo user_id % 100, hoặc chuyển sang Bloom Filter column như đề cập ở trên.
4. Khi nào dùng Exact Blind Index thay vì Trigram?
Khi requirement chỉ là "tìm đúng số điện thoại" — ví dụ login bằng phone, kiểm tra duplicate khi đăng ký. Exact Blind Index đơn giản hơn nhiều, leakage ít hơn, index nhỏ hơn. Chỉ dùng Trigram khi cần substring/prefix search thực sự.
Xem thêm
- Bulk upsert billion rows — xử lý volume lớn trong DB
- Redis vs Memcached — caching layer để giảm search latency