Trước khi viết dòng code đầu tiên của Kidslen, tôi dành vài tuần làm due diligence kỹ thuật cho một nền tảng OTT media analytics cũ. Khác lĩnh vực, nhưng hình dạng thì kề nhau: một hệ thống nuốt khối lượng khổng lồ sự kiện xem từ set-top box và app, lưu lại, rồi báo cáo.
Tôi không thể nêu tên hệ thống, nhà cung cấp, khách hàng của nó, cũng không cho bạn xem code. Không sao, vì phần thú vị chưa bao giờ nằm ở code. Phần thú vị là đến cuối đợt rà soát tôi có trong tay một danh mục khoảng sáu mươi vấn đề cụ thể, và khi ngồi xuống thiết kế sản phẩm của riêng mình, tôi phát hiện mình không bắt đầu từ trang giấy trắng. Tôi bắt đầu từ một danh sách những thứ không được làm.
Đây là thứ hữu ích nhất tôi từng lấy được ra từ một đợt review code, và nó đáng được kể lại.
Một đợt due diligence thật ra sinh ra cái gì
Due diligence không phải code review. Không ai cần ý kiến của bạn về cách đặt tên biến. Câu hỏi bạn phải trả lời là: nếu ngày mai chúng tôi sở hữu hệ thống này, cái gì sẽ đau, đau bao nhiêu, và bao lâu nữa thì đau?
Nên bạn đọc theo kiểu khác. Bạn đi tìm những thứ sẽ tốn tiền về sau:
- Chuyện gì xảy ra khi một lần deploy hỏng?
- Chuyện gì xảy ra khi bảng lớn nhất to lên gấp đôi?
- Ai đọc được dữ liệu production, và có ghi lại ai đã đọc không?
- Nếu người xây cái này nghỉ việc, cái gì ngừng chạy?
- Test cũ nhất là cái nào, và nó còn chạy không?
Tôi ghi từng phát hiện kèm mức độ nghiêm trọng và một dòng hậu quả. Khoảng sáu mươi phát hiện, từ mức mỹ phẩm tới mức thật sự đáng báo động. Dưới đây là những cái cuối cùng định hình Kidslen. Tôi giữ chúng đủ trừu tượng để có thể mô tả cả nghìn hệ thống khác, vì nói thật, đúng là chúng mô tả cả nghìn hệ thống khác.
Những phát hiện thật sự quan trọng
1. Credentials commit thẳng vào repo
Credentials thật, dùng được, nằm trong file được track. Không phải trong .env.example. Nằm trong repo, trong history, ai từng clone đều đọc được — kể cả contractor đã nghỉ.
Xoay secret không sửa được chuyện này; git history là vĩnh viễn, và không ai rewrite cả vì chẳng ai muốn là người làm gãy branch của tất cả mọi người. Dòng hậu quả tôi viết là: “bất kỳ ai từng giữ một bản clone đều có quyền truy cập production.”
2. Không một index nào trên bảng lớn nhất
Collection sự kiện chính nhận khoảng 31 triệu dòng mỗi ngày. Nó không có index nào ngoài primary key.
Nó vẫn “chạy”, theo nghĩa ingest vẫn chạy. Báo cáo thì không thật sự chạy; những truy vấn đáng lẽ vài mili giây thì mất vài phút, và cách khắc phục đã tiến hoá thành: tính sẵn các bản export theo lô qua đêm, thay vì làm cho database nhanh lên. Cả một tầng kiến trúc đã mọc ra chỉ để đi vòng quanh một câu CREATE INDEX còn thiếu.
3. Job dọn dẹp đua với job export
Đây là phát hiện tôi thích nhất trong sự nghiệp tính tới giờ.
Có một job retention xoá các sự kiện thô cũ hơn một ngưỡng, và một job export riêng đọc chính các sự kiện thô đó rồi ghi ra ngoài. Chúng chạy theo lịch độc lập, không phối hợp, không có khái niệm chung nào kiểu “khoảng dữ liệu này đã export an toàn”.
Với timing bình thường, không có gì xảy ra. Với một lần export chậm — ngày dữ liệu lớn, một lần retry, một lần restart — job dọn dẹp có thể xoá đúng phần dữ liệu mà export chưa kịp ghi. Âm thầm, vĩnh viễn, vô hình trên mọi dashboard, vì cả hai job đều báo thành công. Dữ liệu chỉ đơn giản là lặng lẽ thiếu hụt hơn mức mọi người tin.
Không ai từng nhận ra. Đó mới là phần đọng lại: chế độ hỏng không phân biệt được với chạy đúng.
4. Deploy thủ công qua SSH
Triển khai là một con người có quyền SSH, một chuỗi lệnh, và trí nhớ tập thể. Không pipeline, không ghi nhận phiên bản nào đang chạy, không rollback lặp lại được. “Production đang chạy cái gì?” là câu hỏi bạn trả lời bằng cách đăng nhập vào và nhìn.
5. Gần như không có test
Có vài cái test tồn tại. Phần lớn không chạy. Không có CI để phát hiện rằng chúng không chạy. Mọi thay đổi được kiểm chứng bằng cách deploy rồi ngồi nhìn.
6. Phần còn lại
Số còn lại trong sáu mươi là biến thể của cùng vài chủ đề: không có audit trail ai truy cập cái gì; response lỗi là stack trace HTML; truy vấn không giới hạn, không phân trang; một service khổng lồ nơi mọi thứ chạm được mọi thứ; log chứa dữ liệu cá nhân; retry không idempotent, nên retry nghĩa là nhân đôi; monitoring theo dõi tiến trình còn sống hay không chứ không theo dõi nó có đang làm việc gì không.
Không có gì trong đó là thiếu năng lực. Tất cả là một hệ thống lớn lên nhiều năm dưới áp lực giao hàng, mà không ai có nhiệm vụ “nói không”.
Biến phát hiện thành nguyên tắc
Đây là bảng ánh xạ tôi thật sự dùng. Cột trái: cái tôi tìm thấy. Cột phải: nguyên tắc tôi viết vào thiết kế Kidslen trước khi implement bất cứ thứ gì.
| Vấn đề của hệ thống cũ | Quyết định của Kidslen |
|---|---|
| Credentials commit vào repo | Không secret nào nằm trong repository. Secret được inject bằng biến môi trường lúc chạy; CI có bước quét secret; build fail chứ không ship kèm key |
| Không index trên collection 31 triệu dòng/ngày | Mọi truy vấn sản phẩm thật sự chạy đều có index đỡ lưng, viết ngay trong cùng migration với bảng. Thay đổi schema là migration có phiên bản, không bao giờ apply bằng tay |
| Tầng export theo lô sinh ra để né truy vấn chậm | Không có pipeline Parquet/S3 nào cả. Truy vấn thẳng database chính; ở quy mô gia đình nó đủ nhanh, và “đủ nhanh” xoá sổ nguyên một subsystem |
| Job dọn dẹp xoá dữ liệu chưa export | Job retention là thứ duy nhất được xoá, nó làm việc theo ngưỡng tuổi tường minh, và việc xoá không bao giờ gắn với tiến độ của job khác. Export đọc một snapshot nhất quán và không bao giờ xoá gì |
| Deploy thủ công qua SSH | Deploy kích hoạt bằng push qua GitHub Actions runner chạy trên máy của tôi, có cổng smoke test. Smoke test fail thì deploy không hoàn tất. Không ai gõ lệnh triển khai bằng tay |
| Không test, không CI | Test chặn mọi lần merge. Backend 57 test, độ phủ dòng khoảng 96%; dashboard phụ huynh 81 unit và 6 e2e; ops console 81 unit và 7 e2e; companion app 113 test; marketing site unit cộng Playwright |
| Không audit trail | Audit log chỉ ghi thêm, lưu các hành động đặc quyền — ai xem gì, ai đổi policy, ai export hay xoá dữ liệu. Audit log là tính năng sản phẩm, không chỉ là công cụ vận hành |
| Stack trace HTML làm response lỗi | RFC 7807 problem details ở mọi nơi, kèm hợp đồng OpenAPI có tài liệu. Lỗi có kiểu, máy đọc được, và không lộ gì |
| Truy vấn không giới hạn | Mọi endpoint dạng danh sách đều phân trang với page size tối đa bắt buộc. Không có endpoint nào trả về “tất cả” |
| Mọi thứ chạm được mọi thứ | Bảo mật deny-by-default: một endpoint là không chạm tới được trừ khi được cấp quyền tường minh, và mọi truy vấn bị giới hạn trong phạm vi gia đình đang gọi |
| Dữ liệu cá nhân nằm trong log | Log có cấu trúc chỉ mang định danh, không mang nội dung. Tiêu đề và chi tiết xem nằm trong database dưới chính sách retention, không nằm trong file log có vòng đời khác |
| Retry không idempotent | Pipeline sự kiện khử trùng lặp theo event key do client cung cấp, có retry và dead-letter queue. Một lần upload retry không thể đếm đúp thời lượng xem |
| Monitoring chỉ đo liveness | Metric Prometheus đo chính công việc — độ sâu hàng đợi, độ trễ xử lý, số bản ghi dead-letter, số lần đánh giá policy — kèm dashboard Grafana và rule Alertmanager. “Tiến trình còn sống” không phải tín hiệu sức khoẻ |
| Session sống mãi, không bao giờ xoay | Refresh token xoay vòng có phát hiện tái sử dụng; Argon2id để hash mật khẩu; rate limit trên các endpoint xác thực |
| Dữ liệu mặc định giữ mãi mãi | Cửa sổ retention tường minh, đường xoá có tài liệu, và export đầy đủ. Thu thập dữ liệu của trẻ vị thành niên buộc bạn phải xoá được nó |
Sáu mươi phát hiện không ánh xạ một-một thành sáu mươi nguyên tắc — nhiều cái gộp lại thành cùng một nguyên lý. Nhưng mọi dòng ở trên đều truy ngược về một thứ tôi thật sự thấy hỏng, hoặc chỉ cách một đêm chạy chậm là hỏng.
Nguyên tắc tôi không ngờ mình sẽ viết
Thứ giá trị nhất tôi mang về từ đợt review đó lại không nằm trong danh sách. Nó là:
Phần lớn sự phức tạp tồn tại để bù cho một việc đơn giản đã không được làm.
Pipeline theo lô tồn tại vì thiếu một index. Export qua đêm tồn tại vì pipeline theo lô. Các script đối soát tồn tại vì export. Cuộc đua dọn dẹp tồn tại vì các script đối soát. Một câu CREATE INDEX bị bỏ qua đã, sau vài năm, mọc thành một subsystem bốn tầng có kèm một bug mất dữ liệu.
Quan sát đó là lý do Phần 3 của series này chủ yếu nói về những thứ tôi không xây. Mỗi lần bị cám dỗ bởi Kafka, bởi microservices, bởi một kho analytics riêng, tôi lặp lại đúng câu hỏi: mình thêm cái này vì bài toán đòi hỏi, hay vì mình đã bỏ qua một thứ đơn giản hơn?
Ở quy mô gia đình, câu trả lời trung thực gần như luôn là vế thứ hai.
Vì sao chuyện này thuộc về một sản phẩm giữ dữ liệu của trẻ em
Rất dễ đọc những điều trên như chuyện vệ sinh kỹ thuật. Trong sản phẩm này thì không phải.
Kidslen giữ bản ghi xem thuộc về trẻ vị thành niên. Cuộc đua dọn-dẹp-với-export ở hệ thống cũ, trong lĩnh vực của nó, là một sai lệch báo cáo. Cùng loại bug đó ở đây nghĩa là dữ liệu của một gia đình âm thầm sai, hoặc âm thầm mất, hoặc âm thầm bị giữ quá cửa sổ tôi đã hứa. Mỗi trường hợp đều phá đúng thứ duy nhất mà sản phẩm này bán.
Nguyên tắc minh bạch ở Phần 1 — đứa trẻ xem được chính xác những gì bị thu thập — chỉ có nghĩa nếu hệ thống thật sự biết nó đã thu thập gì, giữ đúng, và chứng minh được nó đã xoá đúng cái nó nói đã xoá. Index, audit log, idempotency và job retention ở đây không phải chuyện hậu trường. Chúng chính là cam kết đạo đức, được cài đặt thành code.
Bài tiếp theo: kiến trúc, và danh sách dài hơn nhiều những thứ tôi cố tình bỏ ra ngoài.
Sản phẩm ở kidslen.app nếu bạn muốn xem những nguyên tắc này biến thành cái gì.