TungDaDev's Blog

Thiết kế hệ thống lưu trữ tài liệu trong thời đại số hoá

Cable network
Published on
/36 mins read/

Mở app ngân hàng, bạn chụp mặt trước mặt sau CCCD, quay video khuôn mặt, vài giây sau tài khoản đã mở xong. Tháng sau bạn vay mua xe, ký hợp đồng điện tử ngay trên điện thoại. Cuối tháng nào app cũng có sao kê PDF tải về được.

Từng ấy ảnh, video, PDF sau đó đi đâu?

Phía sau là một hệ thống mà khách hàng gần như không bao giờ nghĩ tới, nhưng thiếu nó thì gần như mọi quy trình số của ngân hàng đứng hình. Ở các tổ chức lớn người ta hay gọi nó là Content Services Platform (nền tảng dịch vụ nội dung), hoặc gọn hơn là hệ thống quản lý tài liệu tập trung.

Bài này viết dưới góc nhìn của người làm Solution Architect trong ngành ngân hàng. Mình dựng một kiến trúc tham chiếu cho hệ thống lưu trữ tài liệu phục vụ cỡ vài chục triệu khách hàng, đi từ ước lượng tải tới từng luồng chính, rồi tới bảo mật, hiệu năng và những chỗ hay vỡ khi chạy thật.

Lưu ý: đây là thiết kế tổng hợp từ các mẫu kiến trúc phổ biến và tài liệu công khai, không mô tả hệ thống cụ thể của bất kỳ tổ chức nào. Mọi con số trong bài là giả định để tính toán minh hoạ.

# tài liệu trong ngân hàng khác gì ảnh trên mạng xã hội

Nghe "lưu file" thì dễ nghĩ: có S3 rồi, ném file lên, lưu cái URL vào database là xong. Với một app chia sẻ ảnh thì gần đúng thật. Ngân hàng thì khác ở mấy điểm làm thay đổi cả kiến trúc.

Thứ nhất, gần như file nào cũng là dữ liệu cá nhân nhạy cảm. Ảnh CCCD, ảnh khuôn mặt, sao kê lương, hợp đồng tín dụng. Từ 1/1/2026, Luật Bảo vệ dữ liệu cá nhân số 91/2025/QH15 có hiệu lực, kèm Nghị định 356/2025/NĐ-CP hướng dẫn. Lộ một file là chuyện pháp lý chứ không chỉ là chuyện kỹ thuật.

Thứ hai, ai được xem file nào là bài toán khó nhất, khó hơn cả lưu trữ. Khách chỉ xem tài liệu của mình. Chuyên viên tín dụng xem hồ sơ khách mình phụ trách, nhưng chỉ khi hồ sơ đang ở bước của họ. Kiểm toán xem được nhiều thứ nhưng chỉ đọc. Một file bị gắn cờ "hạn chế" thì kể cả người tạo cũng có thể bị chặn.

Thứ ba, thời hạn lưu trữ dài và bắt buộc. Chứng từ kế toán dùng ghi sổ phải lưu tối thiểu 10 năm theo quy định về kế toán. Có loại phải lưu lâu hơn. Ngược lại, có dữ liệu cá nhân phải xoá khi hết mục đích xử lý. Hệ thống phải biết file nào được xoá, file nào cấm xoá, kể cả với admin.

Thứ tư, an toàn hệ thống thông tin có quy định riêng cho ngân hàng: Thông tư 09/2020/TT-NHNN quy định từ phân loại cấp độ hệ thống, sao lưu, tới dùng dịch vụ đám mây. Hệ thống lưu tài liệu khách hàng gần như chắc chắn rơi vào nhóm cấp độ cao, kéo theo yêu cầu sao lưu tách biệt, khôi phục được kể cả khi bị ransomware mã hoá, và kiểm tra khôi phục định kỳ.

Còn một điểm nữa là tài liệu không đứng một mình. Một bộ hồ sơ vay đi qua nhiều bước duyệt, mỗi bước thêm file, sửa file, ký file. Nên hệ thống lưu trữ phải nói chuyện được với workflow (BPM), với OCR, với hệ thống ký số, với core banking. Nó là một nền tảng chứ không phải một cái kho.

# yêu cầu và ước lượng tải

Trước khi vẽ ô vẽ mũi tên, mình chốt yêu cầu. Về chức năng, hệ thống cần:

  • Upload file từ app, web nội bộ, hệ thống khác; file lớn phải resume được khi rớt mạng.
  • Xem trực tuyến và tải về, có hỗ trợ tải từng đoạn (HTTP Range) cho video và PDF lớn.
  • Quản lý phiên bản: hợp đồng sửa 3 lần thì giữ đủ 3 bản.
  • Phân quyền chi tiết theo người, vai trò, thuộc tính của file và ngữ cảnh nghiệp vụ.
  • Chia sẻ có thời hạn (link xem trong 24 giờ chẳng hạn).
  • Vòng đời: chuyển tầng lưu trữ, giữ bắt buộc (retention), khoá pháp lý (legal hold), xoá có kiểm soát.
  • Audit đầy đủ: ai xem, ai tải, ai sửa, lúc nào, từ đâu.
  • Phát sự kiện cho hệ thống khác (OCR, workflow, đánh chỉ mục tìm kiếm).

Về phi chức năng thì ưu tiên theo thứ tự: không mất dữ liệu > không lộ dữ liệu > sẵn sàng > nhanh. Thứ tự này quan trọng vì nó quyết định cách xử lý mỗi khi hai yêu cầu đá nhau. Ví dụ cache quyền truy cập lâu thì nhanh hơn, nhưng thu hồi quyền chậm hơn, mà lộ dữ liệu xếp trên nhanh.

Giờ tới phần ước lượng. Giả sử ngân hàng có 30 triệu khách, 20% hoạt động mỗi ngày, tức 6 triệu DAU. Nguồn file mỗi ngày:

  • Khách tự upload (bổ sung hồ sơ, chứng từ): giả sử 5% DAU, mỗi người 3 file, ra 900 nghìn file.
  • Mở tài khoản online: giả sử 30 nghìn tài khoản mới, mỗi tài khoản 4 ảnh/video, ra 120 nghìn file.
  • Back-office và hệ thống sinh ra (hợp đồng, biên bản, ảnh scan ở quầy, báo cáo): giả sử 1,5 triệu file.

Cộng lại khoảng 2,5 triệu file/ngày. Lấy kích thước trung bình 400 KB (ảnh đã nén, PDF scan), ta có:

Đại lượngGiá trị ước lượng
Dữ liệu thô mỗi ngày~1 TB
Dữ liệu thô mỗi năm~370 TB
Dung lượng thật mỗi năm (erasure coding 1,5x / nhân bản 3x)~0,55 PB / ~1,1 PB
Ghi trung bình / đỉnh (hệ số 8)~30 / ~230 file/giây
Đọc trung bình / đỉnh (đọc gấp 8 lần ghi)~230 / ~1.900 lượt/giây
Băng thông đọc giờ cao điểm~6 Gbps
Bản ghi metadata sau 10 năm~9,2 tỷ (~14 TB kể cả index)

Nhìn bảng này mới thấy một điều hơi ngược trực giác: QPS không đáng sợ. Vài trăm tới vài nghìn request mỗi giây thì một cụm service vừa phải là chịu được. Cái đáng sợ là:

  1. Dung lượng cộng dồn không bao giờ giảm. Mỗi năm thêm cả petabyte, giữ 10 năm. Chi phí lưu trữ mới là thứ đốt tiền.
  2. Metadata hàng tỷ bản ghi. Một câu query thiếu index hay một bảng không partition là chết dần chết mòn.
  3. Đỉnh theo lô. Sao kê điện tử tháng cho 30 triệu khách, nếu sinh trong một đêm 6 tiếng, là 30.000.000 / 21.600 ≈ 1.400 file/giây, gấp 6 lần đỉnh ghi bình thường. Chưa kể ngày lương về, ngày khuyến mãi, ngày ra mắt tính năng mới.
  4. Kiểm tra quyền trên mọi lượt đọc. ~1.900 lượt đọc/giây ở đỉnh là ~1.900 lần trả lời câu "người này có được xem file này không", và câu đó phải đúng tuyệt đối.

# kiến trúc tổng thể

Mình tách hệ thống theo trách nhiệm, mỗi khối một lý do để thay đổi:

Đi qua từng khối một chút:

API Gateway là cửa duy nhất từ bên ngoài vào. Nó xác thực token (JWT từ Identity Provider), giới hạn tốc độ theo người dùng và theo client, chặn request quá kích thước, gắn trace id. Nó không quyết định quyền trên từng file, vì gateway không biết file đó thuộc ai.

Upload Service nhận file, chia chunk, kiểm tra sơ bộ, đẩy xuống Storage Manager, rồi phát sự kiện. Nó không giữ trạng thái trong RAM, trạng thái phiên upload nằm ở cache và database, nên scale ngang thoải mái.

Metadata Service giữ "hồ sơ" của file: tên, chủ sở hữu, loại tài liệu, phân loại bảo mật, checksum, phiên bản, tầng lưu trữ, vị trí vật lý, trạng thái, hạn lưu trữ. Mọi câu hỏi "file này là gì, ở đâu" đều đi qua đây.

Storage Manager là lớp trừu tượng trên các kho vật lý. Service khác không bao giờ biết file nằm trên cụm object storage nào, ổ nào. Chúng chỉ biết "file X, tầng hot". Nhờ vậy đổi nhà cung cấp, thêm cụm mới, chuyển tầng đều không phải sửa code ở chỗ khác.

Delivery Service lo xem và tải: stream theo đoạn, kiểm tra quyền, đóng dấu watermark nếu cần, cấp link ký số có thời hạn.

Permission Service trả lời một câu duy nhất, nhưng là câu quan trọng nhất: chủ thể S có được làm hành động A trên đối tượng O trong ngữ cảnh C không?

Event bus (Kafka hoặc RabbitMQ đều được, mình nói về lựa chọn ở dưới) tách phần "phải xong ngay" khỏi phần "xong sau cũng được": quét mã độc, OCR, tạo thumbnail, đánh index, ghi audit, đồng bộ sang hệ thống khác.

Còn một nguyên tắc xuyên suốt là nội dung file và metadata đi hai đường khác nhau. Byte của file chảy qua đường stream, không bao giờ nằm nguyên khối trong heap của service nào. Metadata thì nhỏ, đi qua API và database bình thường. Trộn hai thứ này vào nhau (lưu file vào cột BLOB, đọc cả file vào RAM rồi mới trả) là cách nhanh nhất để có một cụm service OOM liên tục.

# luồng upload: chia chunk, resume và không tin client

File nhỏ (dưới vài MB) thì upload một phát là xong. Nhưng ở Việt Nam, khách mở app trên 4G chập chờn, ngồi xe buýt, đi thang máy. Upload một video eKYC 15 MB mà rớt mạng ở 90% rồi bắt làm lại từ đầu thì khách bỏ luôn. Nên với file lớn, mình dùng resumable upload theo chunk.

Vài quyết định trong luồng này đáng nói kỹ.

Kích thước chunk. Nhỏ quá thì tốn request, lớn quá thì rớt một chunk là mất nhiều. Thường chọn 5 tới 8 MB cho mobile. Con số 5 MB không phải ngẫu nhiên: multipart upload của S3 yêu cầu mỗi phần (trừ phần cuối) tối thiểu 5 MB, nên nếu tầng dưới là S3 tương thích thì chọn khớp luôn cho đỡ phải gom lại.

Checksum hai lớp. Mỗi chunk có checksum riêng để phát hiện hỏng ngay khi nhận. Cả file có SHA-256 do client tính trước và gửi lúc khởi tạo. Khi ghép xong, server tính lại và so. Lệch là từ chối, không có chuyện "chắc không sao". Checksum file còn dùng về sau để chứng minh file không bị sửa kể từ lúc nhận, một yêu cầu rất hay gặp khi có tranh chấp.

Idempotency. Mạng chập chờn thì client sẽ gửi lại. Gửi lại chunk 5 lần thứ hai phải cho cùng kết quả, không tạo hai bản. Khoá idempotent ở đây là (uploadId, chunkIndex). Lệnh hoàn tất cũng phải idempotent: gọi hai lần không được tạo hai file.

Quarantine trước khi ACTIVE. File vừa ghép xong chưa được phép xem. Nó nằm ở vùng cách ly cho tới khi qua quét mã độc và nhận diện định dạng. Đây là chỗ nhiều hệ thống làm ngược: cho xem ngay rồi quét sau, và khoảng thời gian giữa hai bước đó đủ để một file độc hại đi một vòng.

Không tin bất cứ thứ gì client gửi. Đuôi file, header Content-Type, tên file đều do client đặt, giả được hết. Định dạng thật phải nhận diện từ nội dung (magic bytes, thư viện kiểu Apache Tika), rồi so với danh sách cho phép theo từng loại nghiệp vụ. Hồ sơ vay chỉ nhận PDF, JPEG, PNG chẳng hạn. Tên file thì làm sạch trước khi lưu, và tên lưu vật lý là một id do hệ thống sinh, không bao giờ dùng tên client gửi để ghép đường dẫn, tránh path traversal kiểu ../../etc/passwd. Thêm vài loại tấn công đặc thù khi nhận file: zip bomb (file nén vài KB bung ra vài chục GB), file "đa hình" vừa là ảnh hợp lệ vừa là HTML, SVG nhúng script. OWASP File Upload Cheat Sheet liệt kê khá đủ, nên coi đó là checklist tối thiểu.

Có một biến thể hay được đề xuất: upload thẳng lên object storage bằng presigned URL, không đi qua service. Cách này giảm tải cho service rất nhiều, và với app thông thường thì nên dùng. Với ngân hàng mình cân nhắc kỹ hơn: object storage phải mở ra cho client gọi tới (thường không được phép với kho chứa dữ liệu khách hàng nằm trong vùng mạng nội bộ), và bạn mất điểm kiểm soát để chặn sớm file sai định dạng hay quá kích thước. Cách dung hoà là presigned URL trỏ vào một bucket tiếp nhận riêng, đặt ở vùng DMZ, chỉ cho ghi, và mọi file đều phải qua quarantine trước khi được chuyển vào kho thật.

Dedup có nên làm không? Lưu theo hash nội dung thì hai người upload cùng một file chỉ tốn một bản. Nghe tiết kiệm, nhưng dedup xuyên khách hàng mở ra một kênh rò rỉ: kẻ tấn công upload một file đoán trước (ví dụ mẫu hợp đồng điền sẵn thông tin của ai đó) và đo xem upload có "nhanh bất thường" không, từ đó biết file đó đã tồn tại. Nếu muốn dedup thì chỉ làm trong phạm vi một chủ sở hữu, hoặc chấp nhận tốn dung lượng. Với dữ liệu ngân hàng, mình chọn tốn dung lượng.

Luồng đọc nhìn đơn giản hơn nhưng lại là luồng chạy nhiều nhất, nên tối ưu ở đây được lợi nhiều nhất.

Luôn stream, không bao giờ nạp cả file vào bộ nhớ. Với Java thì trả InputStream hoặc StreamingResponseBody, đặt buffer cố định (vài chục KB), để byte chảy từ storage qua service tới client. Có như vậy, một instance 2 GB heap mới phục vụ được hàng trăm lượt tải song song mà không lo heap.

HTTP Range cho phép tải từng đoạn: video tua tới phút thứ 3, PDF 200 trang mở trang 150 mà không phải tải cả file, tải lại phần còn thiếu khi rớt mạng. Nếu file được lưu theo chunk thì Delivery Service phải tự ánh xạ "byte 52.428.800 tới 53.477.375" sang chunk nào, offset nào. Phần này viết sai rất dễ lệch một byte, và lệch một byte là PDF hỏng, nên phải có test cho các ca biên: range nằm gọn trong một chunk, range vắt qua hai chunk, range tới cuối file, range kiểu bytes=-500 (500 byte cuối).

Tầng lạnh trả 202. File đã chuyển sang lưu trữ archive (loại rẻ nhưng lấy ra mất vài phút tới vài giờ, như S3 Glacier) thì không thể trả ngay. Thay vì giữ request treo hàng giờ, trả 202 Accepted kèm link để client hỏi lại, và báo khi file sẵn sàng. Nghiệp vụ cũng phải được báo trước rằng hồ sơ 7 năm tuổi không mở ra ngay được.

Link chia sẻ có hạn. Khi cần đưa file cho người ngoài hệ thống (gửi hợp đồng cho khách qua email chẳng hạn), không gửi file đính kèm mà gửi một link ký số có hạn dùng (vài giờ), gắn với đúng một file, đúng một hành động, có thể thu hồi. Link ngắn kiểu shortlink thì token phải đủ dài và ngẫu nhiên, không được là số tăng dần, nếu không người ta đoán được link của nhau.

Không đưa CDN vào dữ liệu khách hàng. CDN công cộng rất hợp cho ảnh sản phẩm, banner, tài liệu công khai. Với ảnh CCCD hay sao kê thì không: dữ liệu bị cache ở hạ tầng bên thứ ba, nằm ngoài kiểm soát, và chuyện xoá cache khi thu hồi quyền là không đảm bảo. Tài liệu nhạy cảm đi đường riêng, không cache ở edge.

Trả file sao cho trình duyệt không chạy nó. Khi trả nội dung do người dùng upload, luôn đặt Content-Disposition: attachment cho loại không cần xem trực tiếp, thêm X-Content-Type-Options: nosniff, và phục vụ từ một domain riêng khác domain của ứng dụng. Nếu lỡ có một file HTML độc hại lọt qua, nó chạy trên domain kia, không đọc được cookie hay gọi API của app chính.

# lưu trữ theo tầng: nóng, ấm, lạnh

Quay lại con số 1,1 PB/năm (nếu nhân bản 3x). Mười năm là 11 PB. Để tất cả trên SSD thì ngân sách nào cũng toang. Nhưng nhìn kỹ hành vi truy cập: file mới upload được xem rất nhiều trong vài tuần đầu (đang duyệt hồ sơ, đang đối chiếu), sau đó gần như không ai mở, trừ khi có tranh chấp hay kiểm toán.

Nên chia tầng:

TầngLưu ở đâuDữ liệu nàoĐộ trễ đọcGiá tương đối
HotObject storage trên SSD/NVMe30–90 ngày gần nhất, hồ sơ đang xử lýmili giâycao
WarmObject storage trên HDD, erasure codingtới vài nămchục tới trăm mili giâytrung bình
ColdArchive (tape, Glacier-like), WORMphần còn lại tới hết hạn lưuphút tới giờthấp

Việc chuyển tầng do Lifecycle Service làm theo chính sách khai báo, chạy nền, không ai bấm tay. Mỗi loại tài liệu có chính sách riêng: ảnh eKYC có thể xuống warm sau 30 ngày, hợp đồng tín dụng giữ hot tới khi tất toán khoản vay rồi mới xuống. Lúc chuyển tầng phải copy xong, verify checksum xong, cập nhật metadata xong, rồi mới xoá bản cũ. Làm ngược thứ tự này mà lỗi giữa chừng thì metadata trỏ vào chỗ trống.

Erasure coding thay cho nhân bản 3 lần ở tầng warm/cold là chỗ tiết kiệm lớn nhất. Thay vì giữ 3 bản đầy đủ (tốn gấp 3), chia file thành k mảnh dữ liệu cộng m mảnh chẵn lẻ, mất tối đa m mảnh vẫn dựng lại được. Ví dụ Reed-Solomon 6+3 tốn 1,5x mà chịu được mất 3 mảnh cùng lúc. Đổi lại, đọc và sửa chữa tốn CPU và băng thông hơn. Dropbox kể khá chi tiết cách họ làm điều này ở quy mô exabyte trong loạt bài về Magic Pocket: khối mới ghi được nhân bản trước, sau đó mới gom lại và erasure code, đúng tinh thần "nóng thì ưu tiên tốc độ, nguội thì ưu tiên chi phí".

WORM và Object Lock cho tài liệu bắt buộc lưu. Write Once Read Many: ghi một lần, trong thời hạn khoá thì không ai sửa hay xoá được, kể cả root của hệ thống lưu trữ. Đây là lớp bảo vệ cuối cùng trước ransomware và trước cả người trong nhà. Hệ thống lưu trữ hỗ trợ hai chế độ: một chế độ cho phép người có quyền đặc biệt gỡ khoá, và một chế độ không ai gỡ được. Tài liệu bắt buộc lưu theo luật thì dùng loại thứ hai, kèm legal hold để khoá vô thời hạn khi có tranh chấp.

Đặt key cho object. Với S3, AWS cam kết tối thiểu 3.500 request ghi và 5.500 request đọc mỗi giây cho mỗi prefix được phân vùng, không giới hạn số prefix. Hệ thống tự phân vùng theo tải, nhưng mất thời gian, và trong lúc đó client nhận 503 Slow Down. Key dạng {loại-tài-liệu}/{yyyy}/{mm}/{dd}/{uuid} thì toàn bộ ghi của một ngày dồn vào một prefix. Đêm chạy sao kê 1.400 file/giây vẫn dưới ngưỡng 3.500, nhưng không còn nhiều khoảng trống. Thêm vài ký tự đầu của hash id vào đầu key ({hash[0:2]}/{loại}/...) là tải tự rải đều. Với object storage tự dựng (MinIO, Ceph) thì giới hạn khác, nhưng nguyên tắc rải key vẫn có ích.

# metadata: hàng tỷ bản ghi và vài câu query

Metadata nhỏ nhưng là nơi dễ thành nút thắt nhất. Mỗi file có một bản ghi chính, cộng các bản ghi phiên bản, quan hệ (file thuộc hồ sơ nào), thẻ, trạng thái xử lý. Mười năm cỡ 9 tỷ bản ghi.

Chọn PostgreSQL hay MongoDB? Theo mình thì cả hai đều làm được, quan trọng là mô hình truy cập. Metadata của tài liệu khá đa dạng (hợp đồng có số hợp đồng, kỳ hạn; ảnh eKYC có kết quả so khớp khuôn mặt), nên phần thuộc tính mở rộng hợp với document hoặc cột JSONB. Phần quyền và quan hệ thì cần ràng buộc, transaction, nên hợp với quan hệ. Một cấu hình hay gặp là metadata trên MongoDB (hoặc PostgreSQL + JSONB), quyền trên PostgreSQL. Đừng chọn theo phong trào, chọn theo câu query bạn sẽ chạy nhiều nhất.

Và đây là mấy câu query chạy nhiều nhất, thiết kế index theo đúng chúng:

  1. Lấy metadata theo fileId: khoá chính, lúc nào cũng nhanh.
  2. Liệt kê tài liệu của một khách hàng, mới nhất trước: index (ownerId, createdAt desc).
  3. Liệt kê tài liệu của một hồ sơ nghiệp vụ: index (caseId, docType).
  4. Tìm file tới hạn chuyển tầng hoặc hết hạn lưu: index (tier, nextTransitionAt), quét theo lô.

Mọi thứ khác (tìm theo nội dung, theo tên gần đúng, theo thuộc tính tuỳ ý) đẩy sang search index (Elasticsearch/OpenSearch) được cập nhật bất đồng bộ qua event bus. Database chính không gánh full-text search.

Shard theo gì? Theo ownerId (khách hàng hoặc đơn vị sở hữu) thì query số 2 nằm gọn một shard, và dữ liệu của một khách nằm cùng chỗ, tiện cho cả việc xoá dữ liệu cá nhân khi khách yêu cầu. Cẩn thận với "khách hàng siêu to": một doanh nghiệp lớn với hàng chục triệu chứng từ sẽ làm lệch shard. Với những chủ sở hữu đó thì cần thêm một tầng chia nhỏ (ownerId + bucket).

Bảng audit và bảng quyền thì partition theo thời gian. Audit log tăng nhanh nhất (mỗi lượt xem một dòng), chỉ ghi thêm, và hầu như chỉ query theo khoảng thời gian. Partition theo tháng hoặc năm giúp xoá dữ liệu hết hạn bằng cách DROP PARTITION (tức thì) thay vì DELETE hàng tỷ dòng (khoá bảng, phình WAL, replica trễ hàng giờ).

# phân quyền: RBAC cho "được làm gì", ABAC cho "trong ngữ cảnh nào"

Đây là phần mình thấy nhiều thiết kế làm hời hợt nhất, trong khi nó là phần dễ gây thiệt hại nhất.

RBAC thuần (người dùng có vai trò, vai trò có quyền) không đủ cho tài liệu. "Chuyên viên tín dụng được xem hồ sơ vay" là đúng nhưng thiếu: chuyên viên chi nhánh A có được xem hồ sơ chi nhánh B không? Hồ sơ đã chuyển sang bước thẩm định thì chuyên viên lập hồ sơ còn sửa được không? File bị gắn cờ hạn chế thì sao?

Cách mình chọn là tách hai trục:

  • RBAC trả lời được làm những hành động gì: vai trò viewer có READ, editor có thêm WRITE, owner có thêm SHARE và DELETE. Vai trò có thể kế thừa.
  • ABAC trả lời trong phạm vi nào: thuộc tính của file (chủ sở hữu, đơn vị, phân loại bảo mật, cờ hạn chế), của người dùng (đơn vị, cấp bậc), và của ngữ cảnh (bước workflow hiện tại, giờ làm việc, mạng nội bộ hay ngoài).

Quyết định cuối là giao của hai trục, theo thứ tự ưu tiên rõ ràng. Một thứ tự hợp lý:

1. Restriction (cờ hạn chế, legal hold)  -> DENY thắng tất cả
2. Ownership (chủ sở hữu)                -> quyền đầy đủ trong phạm vi chính sách
3. Explicit share (được chia sẻ)          -> theo vai trò được cấp, có hạn
4. Organizational scope (đơn vị, vai trò) -> theo RBAC + thuộc tính đơn vị
5. Mặc định                               -> DENY

Hai nguyên tắc không được phá: deny mặc định (không có quy tắc nào cho phép thì từ chối) và deny tường minh luôn thắng allow. Nghe hiển nhiên, nhưng rất nhiều lỗ hổng phân quyền sinh ra từ một nhánh else trả về true.

Identity Provider (Keycloak hay tương đương) chỉ làm một việc: xác thực bạn là ai. Quyết định bạn được làm gì nằm ở Permission Service. Nhồi hết quyền chi tiết vào token có vẻ tiện, nhưng token sống 5–15 phút, thu hồi quyền thì token cũ vẫn dùng được tới khi hết hạn, và token phình to theo số quyền.

Cache quyền: nhanh mà vẫn đúng. ~2.000 lượt kiểm tra quyền mỗi giây ở đỉnh, mỗi lượt có thể phải join vài bảng. Cache là bắt buộc. Cache key là (subject, object, action) hoặc gom theo (subject, object), TTL ngắn (vài phút tới 15 phút), và xoá chủ động khi cấp hoặc thu hồi quyền. Với tỷ lệ trúng cache 95%, database quyền chỉ còn nhận ~110 query/giây ở đỉnh. Nhưng nhớ thứ tự ưu tiên ở đầu bài: không lộ dữ liệu xếp trên nhanh. Nên khi thu hồi quyền khẩn cấp (nhân viên nghỉ việc, tài khoản bị chiếm), phải có đường xoá cache ngay trên mọi node, không chờ TTL. Thông tư 09 cũng yêu cầu thu hồi quyền truy cập ngay khi nhân sự nghỉ việc.

Bloom filter để chặn sớm. Một kiểu tấn công rẻ tiền là dò fileId ngẫu nhiên. Mỗi lượt dò không tồn tại vẫn tốn một lượt hỏi metadata và có khi một lượt hỏi quyền. Một Bloom filter chứa tập id đang tồn tại trả lời "chắc chắn không có" chỉ với vài byte bộ nhớ cho mỗi phần tử, chặn được phần lớn lượt dò trước khi chạm database. Bloom filter có thể báo nhầm "có" (khi đó vẫn đi tiếp kiểm tra thật) nhưng không bao giờ báo nhầm "không". Và id thì nên là UUID ngẫu nhiên chứ đừng tăng dần, dò mới khó ngay từ đầu.

# mã hoá: envelope encryption và chuyện giữ khoá

Mã hoá khi truyền (TLS ở mọi chặng, mTLS giữa các service) là mặc định, không cần bàn nhiều. Mã hoá khi lưu thì có nhiều tầng, và chọn tầng nào quyết định bạn chống được ai.

Mã hoá ở tầng đĩa hay tầng hệ thống lưu trữ chống được việc mất ổ cứng hay mất băng sao lưu. Nhưng nó không chống được một service bị chiếm quyền, vì với service đó dữ liệu vẫn hiện ra dạng rõ. Với tài liệu nhạy cảm, mình mã hoá ở tầng ứng dụng, theo mô hình envelope encryption:

Mỗi file có một khoá dữ liệu (DEK) ngẫu nhiên riêng, dùng để mã hoá nội dung. DEK được "bọc" bằng một khoá chủ (KEK) nằm trong KMS hoặc HSM, và bản đã bọc lưu cùng metadata. Muốn đọc file thì gửi DEK đã bọc vào KMS để mở, nhận DEK rõ trong bộ nhớ, giải mã, rồi bỏ đi.

Mô hình này cho mấy lợi ích rất thực tế:

  • Xoay khoá rẻ. Đổi KEK chỉ cần bọc lại các DEK (vài trăm byte mỗi file), không phải mã hoá lại hàng petabyte dữ liệu.
  • Giới hạn thiệt hại. Lộ một DEK chỉ lộ một file.
  • Xoá bằng cách huỷ khoá (crypto-shredding). Với dữ liệu nằm trong bản sao lưu hay trên băng từ, xoá vật lý gần như không thể. Huỷ DEK (hoặc KEK của cả một nhóm dữ liệu) là dữ liệu thành rác vĩnh viễn. Đây là cách thực tế nhất để đáp ứng yêu cầu xoá dữ liệu cá nhân mà không phải lục từng cuộn băng.
  • Mọi lần giải mã đều để lại dấu vết ở KMS, thành một lớp audit độc lập với ứng dụng.

Chi phí là thêm một lượt gọi KMS cho mỗi lần mở file. Ở ~1.900 lượt đọc/giây thì KMS phải chịu được tải đó, hoặc bạn cache DEK đã mở trong bộ nhớ vài phút (đánh đổi một chút an toàn lấy hiệu năng, và phải ghi rõ đánh đổi này trong tài liệu thiết kế).

Bí mật và khoá truy cập (mật khẩu database, khoá truy cập object storage) nằm trong Vault hoặc secret manager, cấp động, có hạn, không bao giờ nằm trong file cấu hình trên Git. Mỗi service một danh tính riêng, quyền tối thiểu: Delivery Service chỉ đọc, không xoá; Lifecycle Service được chuyển tầng nhưng không đọc nội dung.

# mổ xẻ một sự cố thật: Capital One 2019

Nói về quyền tối thiểu thì không có ví dụ nào rõ hơn vụ Capital One năm 2019, được phân tích công khai khá nhiều, ví dụ bài của Snyk và CloudSploit. Chuỗi tấn công theo các phân tích này đại ý như sau:

  1. Một web application firewall chạy trên máy ảo EC2 bị cấu hình sai, cho phép kẻ tấn công khiến nó gửi request tới địa chỉ tuỳ ý (SSRF).
  2. Kẻ tấn công trỏ request đó tới dịch vụ metadata nội bộ của máy ảo (địa chỉ 169.254.169.254). Phiên bản dịch vụ metadata khi đó (IMDSv1) trả lời bất kỳ request nào từ trong máy, không cần xác thực.
  3. Dịch vụ metadata trả về khoá truy cập tạm thời của IAM role gắn với máy ảo đó.
  4. Role của cái firewall này có quyền liệt kê và đọc hàng trăm bucket S3, trong khi firewall gần như không có lý do gì cần đọc dữ liệu khách hàng.
  5. Dùng khoá đó, kẻ tấn công tải dữ liệu từ hơn 700 bucket. Hơn 100 triệu người bị ảnh hưởng.

Cũng phải nói cho công bằng: có tài liệu học thuật cho rằng cơ chế chính xác ở bước 1 chưa được xác nhận hoàn toàn. Nhưng bài học về thiết kế thì không đổi, và nó áp thẳng vào hệ thống lưu trữ tài liệu:

  • Mỗi thành phần một danh tính, quyền đúng việc nó làm. Không có bước nào ở trên dùng lỗ hổng phức tạp. Thiệt hại lớn là do một thành phần ngoại vi lại có quyền đọc cả kho dữ liệu.
  • Chặn đường tới metadata service. Bắt buộc dùng IMDSv2 (phải lấy token theo phiên trước), giảm số hop cho phép, chặn egress từ ứng dụng tới các địa chỉ nội bộ nhạy cảm.
  • Kho chứa dữ liệu khách hàng không được truy cập trực tiếp bằng quyền chung. Mọi lượt đọc đi qua Delivery Service có kiểm tra quyền theo từng file. Kể cả khi ai đó có được khoá truy cập object storage, nội dung vẫn được mã hoá bằng DEK mà chỉ KMS mở được, và KMS có chính sách riêng.
  • Giám sát hành vi bất thường. Một danh tính đột ngột liệt kê hàng trăm bucket là tín hiệu phải báo động ngay.

# hiệu năng và chịu tải: những chỗ hay vỡ

Phần này mình gom những kiểu hỏng phổ biến khi hệ thống lưu trữ chạy dưới tải thật. Không cái nào mới, nhưng cái nào cũng từng làm sập không ít hệ thống.

Connection pool cạn mà metric nhìn vẫn đẹp. Tình huống điển hình: lượng request tăng, mỗi request giữ một connection database lâu hơn bình thường (vì một query chậm, hoặc vì code gọi service khác trong khi đang giữ transaction). Pool HikariCP 20 connection cạn, request xếp hàng chờ connection rồi timeout. CPU database lúc đó lại thấp, vì database có làm gì đâu, nó đang chờ. Ai chỉ nhìn CPU sẽ kết luận "database khoẻ", rồi đi tìm lỗi sai chỗ. Metric phải xem là thời gian chờ lấy connection (hikaricp_connections_pending, connection acquire time), và quy tắc vàng: không gọi mạng ra ngoài khi đang giữ transaction.

Fan-out nhân tải. Một sự kiện "file mới" đi qua exchange kiểu fan-out tới 8 consumer, mỗi consumer lại gọi ngược Metadata Service để lấy chi tiết. Ngày thường không sao. Đêm chạy sao kê 1.400 file/giây thành 11.200 lượt gọi metadata mỗi giây, gấp nhiều lần lượng đọc bình thường. Cách chữa: sự kiện mang đủ thông tin consumer cần (event-carried state transfer), mỗi consumer chỉ đăng ký đúng loại sự kiện nó quan tâm (routing key thay cho fan-out mù), và tính trước hệ số nhân này khi lập kế hoạch dung lượng.

Timeout dây chuyền. Delivery gọi Metadata với timeout 30 giây, Metadata gọi Permission với timeout 30 giây, Permission gọi database với timeout 30 giây. Một tầng chậm là thread ở mọi tầng phía trên treo theo, rồi hết thread, rồi sập dây chuyền. Timeout phải theo ngân sách giảm dần từ ngoài vào trong (ví dụ gateway 10 giây, service 3 giây, database 1 giây), kèm circuit breaker để ngắt sớm, và bulkhead tách pool thread theo từng phụ thuộc để một thứ chậm không ăn hết tài nguyên của thứ khác.

Retry thành DDoS tự gây. Client retry ngay khi lỗi, service retry khi gọi tầng dưới, thư viện cũng tự retry. Ba tầng mỗi tầng 3 lần là 27 lần cho một lỗi. Retry phải có exponential backoff kèm jitter, giới hạn tổng (retry budget), và chỉ retry lỗi tạm thời với thao tác idempotent.

Backpressure khi chạy lô. Job sinh sao kê không được đổ hết 30 triệu yêu cầu vào hàng đợi rồi để consumer gánh. Hàng đợi phải có giới hạn, producer chậm lại khi consumer không kịp, và lô lớn chạy với tốc độ được kiểm soát (ví dụ tối đa 1.500 file/giây), để phần còn lại của hệ thống vẫn phục vụ khách bình thường. Tốt nhất là tách hẳn cụm xử lý lô khỏi cụm phục vụ trực tuyến.

Câu lệnh nguy hiểm không có rào chắn. Một câu DELETE thiếu điều kiện WHERE trên bảng quyền là đủ để toàn bộ người dùng mất quyền trong một giây. Rào chắn cần có: tài khoản ứng dụng không có quyền xoá hàng loạt; thao tác dữ liệu thủ công trên production phải qua review hai người, chạy trong transaction, kiểm tra số dòng bị ảnh hưởng trước khi commit; với bảng quan trọng thì dùng xoá mềm và giữ lịch sử thay đổi; và luôn có cách khôi phục tới một thời điểm (point-in-time recovery) được diễn tập thật, không chỉ ghi trong tài liệu.

Rò bộ nhớ chậm. Stream mở mà không đóng (quên try-with-resources), cache không giới hạn kích thước, buffer bị giữ tham chiếu. Service không chết ngay mà chết sau vài ngày, đúng lúc ít người trực nhất. Phải theo dõi heap sau mỗi lần full GC theo thời gian. Đường đi lên dần đều là rò, không phải "do tải".

# khôi phục thảm hoạ và sao lưu

Với hệ thống lưu tài liệu, có hai câu phải trả lời được bằng con số: mất tối đa bao nhiêu dữ liệu (RPO) và bao lâu thì chạy lại (RTO).

Dữ liệu được nhân bản giữa ít nhất hai trung tâm dữ liệu hoặc hai vùng sẵn sàng. Object storage nhân bản bất đồng bộ giữa hai site. Metadata database thì replication đồng bộ trong site, bất đồng bộ sang site dự phòng.

Chỗ khó nhất ít người để ý: metadata và nội dung phải khớp nhau khi khôi phục. Khôi phục database về 2 giờ sáng trong khi object storage ở 3 giờ sáng thì có những file tồn tại mà metadata không biết (rác, tốn tiền), và tệ hơn là metadata trỏ vào file chưa kịp nhân bản (lỗi khi mở). Cách xử lý: object là bất biến (sửa file là tạo object mới, không ghi đè), metadata chỉ chuyển sang ACTIVE sau khi object đã có ở đủ số bản sao yêu cầu, và có job đối soát định kỳ hai chiều để tìm object mồ côi và metadata trỏ vào hư không.

Sao lưu thì phải có bản tách biệt và bất biến (WORM hoặc offline), để ransomware có chiếm được quyền admin cũng không xoá được. Và quan trọng nhất là diễn tập khôi phục thật, định kỳ, có đo thời gian. Bản sao lưu chưa từng khôi phục thử thì chưa chắc đã dùng được.

# quan sát hệ thống

Mỗi service xuất metric theo mẫu RED (Rate, Errors, Duration), kèm metric riêng của lĩnh vực: số phiên upload đang mở, số file nằm trong quarantine và tuổi của file lâu nhất, độ trễ quét mã độc, tỷ lệ trúng cache quyền, số lượt bị từ chối quyền theo client, dung lượng theo tầng, hàng đợi chuyển tầng.

Distributed tracing (OpenTelemetry) gắn trace id từ gateway tới tận storage. Khi khách báo "không mở được hợp đồng", tra trace là biết request chết ở đâu, thay vì grep log của tám service.

SLO đặt theo trải nghiệm người dùng, ví dụ: 99,9% lượt mở tài liệu ở tầng hot trả byte đầu tiên dưới 500 ms; 99% file upload xong được chuyển sang ACTIVE trong 2 phút. Cảnh báo dựa trên tốc độ đốt error budget, không dựa trên ngưỡng CPU, để người trực chỉ bị gọi dậy khi khách hàng thật sự bị ảnh hưởng.

Audit log là một luồng riêng, chỉ ghi thêm, được ký hoặc chuỗi hoá bằng hash để phát hiện sửa xoá, lưu tách biệt khỏi hệ thống nghiệp vụ. Người vận hành hệ thống không được có quyền sửa audit log của chính hệ thống đó.

# chọn công nghệ

Mình cố tình để phần này cuối, vì công nghệ là thứ thay được, còn các quyết định ở trên thì không. Một bộ chọn hợp lý cho đội Java:

Thành phầnLựa chọn hay gặpGhi chú
ServiceJava 21, Spring Boot 3, chạy trên KubernetesVirtual thread hợp với tải I/O như stream file
Object storageS3, hoặc tự dựng MinIO / Ceph on-premOn-prem nếu quy định không cho dữ liệu ra ngoài
MetadataPostgreSQL (JSONB) hoặc MongoDBChọn theo mô hình query
QuyềnPostgreSQL + Redis cachePartition bảng lớn theo thời gian
Event busKafka hoặc RabbitMQKafka nếu cần replay và throughput cao; RabbitMQ nếu cần routing linh hoạt
Xác thựcKeycloak hoặc IdP doanh nghiệp (OIDC)Chỉ xác thực, không giữ quyền chi tiết
KhoáHSM / KMS, Vault cho secret
Quan sátPrometheus, Grafana, OpenTelemetry, ELK

# nên làm gì tiếp

Nếu bạn đang thiết kế hoặc rà lại một hệ thống lưu trữ tài liệu, theo mình nên bắt đầu từ ba việc trước khi đụng tới chọn công nghệ: viết ra bảng ước lượng tải của riêng mình (đặc biệt là các đỉnh theo lô), vẽ ma trận quyền cho từng loại tài liệu và thứ tự ưu tiên khi các quy tắc đá nhau, và liệt kê từng loại tài liệu với thời hạn lưu theo quy định. Ba thứ đó quyết định gần hết kiến trúc.

Tài liệu nên đọc thêm:

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.