Câu hữu ích nhất trong tài liệu thiết kế của Kidslen không nói về những gì nó chứa. Nó là một ràng buộc:

Tenant lớn nhất trong thực tế là một gia đình với bốn đứa trẻ và sáu nền tảng đã kết nối.

Mọi thứ dưới đây suy ra từ đó. Phần 2 kết thúc bằng nhận xét rằng phần lớn sự phức tạp tôi thấy ở hệ thống cũ tồn tại để bù cho một việc đơn giản đã không được làm. Bài này là nỗ lực để không lặp lại điều đó, bằng cách viết ra những thứ tôi cố tình bỏ ra ngoài và vì sao.

Stack, nói ngắn gọn

  • Backend: Kotlin 2, Spring Boot 3.4, PostgreSQL. Một service, một container, api.kidslen.app.
  • Dashboard phụ huynh: React 19 + Vite, app.kidslen.app.
  • Ops console: React 19 + Vite, ops.kidslen.app, đứng sau Cloudflare Access.
  • Companion app: React Native, cài trên điện thoại của con.
  • Marketing site: Astro, kidslen.app.
  • Hạ tầng: một LXC Proxmox duy nhất đặt tại nhà, GitHub Actions runner chạy trên máy của tôi (một Linux runner trong LXC, một macOS runner trên Mac mini để build iOS), deploy kích hoạt bằng push qua cổng smoke test, mọi thứ lộ ra ngoài qua Cloudflare Tunnel, không mở cổng inbound nào.
  • Observability: Prometheus, Grafana, Alertmanager.

Đó là toàn bộ bản đồ. Nó gọn trong một đoạn văn, và đấy chính là điểm cần nói.

Những thứ tôi không xây

Không Kafka, không message broker

Hệ thống cũ tôi rà soát đã mọc ra một tầng streaming. Đường ingest của Kidslen là một bảng trong PostgreSQL.

Sự kiện tới từ companion app, được insert vào bảng hàng đợi dưới dạng JSONB kèm cột trạng thái, và một processor nhặt chúng lên. Đó là toàn bộ cái hàng đợi. Nó cho tôi tính bền, insert cùng transaction với phần dữ liệu còn lại, SELECT ... FOR UPDATE SKIP LOCKED cho worker chạy song song, và khả năng soi backlog bằng một câu query thay vì bằng một CLI mà tôi phải nhớ flag.

Một broker xứng đáng tồn tại khi bạn có nhiều consumer độc lập, fan-out xuyên service, replay giữa các team, hoặc throughput mà database không nuốt nổi. Tôi có một consumer, một service, và lượng sự kiện mà cái laptop cũng không buồn để ý. Thêm Kafka là thêm một hệ thống có trạng thái thứ hai phải vận hành, giám sát, backup, nâng cấp và suy luận — đổi lại giải quyết một vấn đề tôi không có.

Không pipeline Parquet/S3

Đây là bài học trực tiếp từ Phần 2. Bản export đêm của nền tảng cũ tồn tại vì truy vấn báo cáo trên kho chính quá chậm, và chúng chậm vì thiếu một index.

Báo cáo của Kidslen — thời lượng xem theo nền tảng, theo ngày, theo từng đứa trẻ — là câu SQL trên chính bảng đang sống, với index được thiết kế cùng truy vấn trong cùng một migration. Một năm lịch sử xem của một gia đình là số dòng bé tí theo chuẩn database. Không có kho analytics, không job đêm, không script đối soát, và do đó không tồn tại khả năng xảy ra cuộc đua dọn-dẹp-với-export — phát hiện tôi thích nhất trong đợt review.

Bỏ một subsystem là bỏ luôn mọi bug mà subsystem đó từng có thể có. Đó là công việc tăng độ tin cậy rẻ nhất trên đời.

Không microservices

Một backend deploy được. Auth, ingest, xử lý, policy engine, alert, export và bề mặt quản trị đều nằm chung một codebase, phân tách bằng ranh giới module chứ không phải ranh giới mạng.

Tách chúng ra sẽ mua cho tôi khả năng scale độc lập mà tôi không cần và deploy độc lập mà tôi không muốn, đổi lại tôi phải trả giá bằng transaction phân tán, tracing xuyên service, lệch phiên bản giữa các service, và thêm năm thứ phải deploy lúc 11 giờ đêm. Ranh giới tôi thật sự quan tâm — “nhánh code này không được thấy dữ liệu của gia đình khác” — được thực thi bằng phân quyền deny-by-default và scoping truy vấn, chứ không bằng một cú nhảy mạng. Mà một cú nhảy mạng vốn dĩ chưa bao giờ là cơ chế phân quyền.

Không tự viết auth, không tự viết crypto

Refresh token xoay vòng có phát hiện tái sử dụng, hash mật khẩu bằng Argon2id, rate limit trên endpoint auth. Chuẩn mực, nhàm chán, lấy từ thư viện. Phần thú vị của sản phẩm này không nằm ở cái form đăng nhập.

Không Kubernetes

Một container và một database được quản lý. Deploy là một cú push kích hoạt runner của chính tôi: build, chạy test, triển khai, rồi chạy smoke test trước khi lần deploy đó được tính là xong. Rollback là deploy lại image trước. Tôi giải thích được toàn bộ môi trường production cho một kỹ sư khác trong năm phút, và đó mới là chỉ số tôi thật sự quan tâm khi tôi là người duy nhất trực.

Cơ chế làm việc thật sự

Thứ tôi có xây là một pipeline duy nhất, và nó đáng được đi qua từng bước, vì mọi thứ sản phẩm làm đều là một bước trong đó.

companion app
      │  sự kiện theo lô (POST, mỗi sự kiện một idempotency key)
      ▼
  bảng hàng đợi ────────►  processor  ──────────►  policy engine
  (payload JSONB,          (dedup, retry,          (giới hạn, giờ ngủ,
   status, attempts)        dead-letter)            chặn, từ khoá)
                                  │                       │
                                  ▼                       ▼
                            bản ghi xem                 alert
                                  │                       │
                                  └─────────┬─────────────┘
                                            ▼
                                   push  +  SSE  ──►  dashboard phụ huynh

1. Ingest. Companion app gom sự kiện theo lô rồi POST lên. Mỗi sự kiện mang một key do client sinh. Nhiệm vụ duy nhất của endpoint là validate, phân quyền, và insert vào bảng hàng đợi dưới dạng JSONB. Nó không diễn giải gì, nghĩa là một thay đổi schema phía nền tảng không thể làm gãy ingest — payload thô vẫn hạ cánh an toàn kể cả khi tôi chưa hiểu hết nó. Điều đó đã cứu tôi một lần rồi.

2. Dedup. Event key là duy nhất. Một lần upload retry — mạng di động chập chờn, app bị kill giữa request, backend restart — lần thứ hai không insert gì. Đây là cách sửa một phát hiện của hệ thống cũ: retry không idempotent nghĩa là retry âm thầm nhân đôi thời lượng xem của ai đó, và trong một sản phẩm mà một con số vượt ngưỡng sẽ gửi cảnh báo, đếm đúp không phải chuyện mỹ phẩm. Nó gửi đi một lời buộc tội sai.

3. Xử lý. Worker giành dòng bằng SKIP LOCKED, chuẩn hoá payload thành bản ghi xem, rồi đánh dấu xong. Lỗi thì tăng bộ đếm lần thử và giãn thời gian; quá ngưỡng thì dòng đó chuyển sang trạng thái dead-letter và ngừng retry vô hạn. Độ sâu dead-letter là một metric có rule Alertmanager, vì chế độ hỏng làm tôi sợ không phải “xử lý crash to tiếng”, mà là “xử lý đã lặng lẽ vứt bỏ sự kiện của một nền tảng suốt chín ngày”.

4. Policy engine. Mỗi bản ghi xem mới được đánh giá với các policy đang bật của đứa trẻ đó: giới hạn screen time ngày, khung giờ ngủ, nền tảng bị chặn, cảnh báo từ khoá. Đây cố ý là một hàm nhỏ, thuần, được test rất kỹ — cho vào bản ghi và policy, trả ra vi phạm. Nó là mảnh đáng test nhất, vì nó là mảnh sinh ra lời buộc tội.

5. Alert. Một vi phạm sinh ra alert, gửi qua push notification và qua SSE tới mọi dashboard đang mở. App của đứa trẻ cũng được báo. Trong một sản phẩm minh bạch, một cảnh báo về bạn cũng là cảnh báo gửi cho bạn.

6. SSE, không phải WebSocket. Dashboard cần cập nhật từ server xuống client và chỉ vậy thôi; hành động của phụ huynh là các HTTP request bình thường. Server-sent events là một chiều, chạy trên HTTP thuần, tự kết nối lại trong trình duyệt, và đi qua Cloudflare Tunnel không cần xử lý đặc biệt. WebSocket sẽ cho tôi tính hai chiều mà tôi không dùng tới, kèm một vòng đời kết nối riêng để làm sai.

7. Retention. Một job duy nhất, thứ duy nhất trong hệ thống được phép xoá dữ liệu xem, làm việc theo ngưỡng tuổi tường minh và không gắn với tiến độ của bất kỳ job nào khác. Cộng thêm export đầy đủ theo yêu cầu và xoá đầy đủ theo yêu cầu, vì lời hứa minh bạch ở Phần 1 chỉ có thật nếu dữ liệu thật sự đi ra được.

Vì sao PostgreSQL với JSONB

Payload trong hàng đợi là JSONB; bản ghi xem đã chuẩn hoá là cột quan hệ bình thường.

Sự tách đôi đó là cố ý. Hình dạng sự kiện thô do sáu nền tảng bên thứ ba quyết định và chúng đổi mà không báo tôi, nên ở rìa hệ thống nó phải không schema. Còn dữ liệu tôi truy vấn, báo cáo và thực thi policy là của tôi, ổn định, và quan hệ. JSONB cho tôi nhận đầu vào lộn xộn mà không để sự lộn xộn lọt vào mô hình lõi, và tôi vẫn query được nó kèm index khi cần điều tra.

Một database cũng nghĩa là một bản backup, một quy trình phục hồi, một chỗ duy nhất chứa “mọi thứ về gia đình này”. Khi một phụ huynh yêu cầu xoá dữ liệu, tôi muốn đó là một transaction, không phải một cuộc dọn dẹp phân tán qua bốn hệ thống mà có thể thành công một nửa.

Vì sao quy mô gia đình đổi mọi quyết định

Cái bẫy của loại sản phẩm này là thiết kế cho quy mô bạn hy vọng thay vì quy mô bạn có. Thiết kế cho quy mô tưởng tượng không miễn phí — bạn trả ngay lập tức, bằng bề mặt vận hành và bằng mọi thay đổi trong tương lai — và bạn trả bằng đồng tiền của một đội một người.

Ở quy mô gia đình:

  • Một năm lịch sử xem là nhỏ, nên báo cáo có thể là truy vấn trực tiếp.
  • Ingest bùng theo đợt nhưng tí hon, nên hàng đợi có thể là một cái bảng.
  • Chỉ có một consumer, nên không có broker.
  • Chỉ có một thứ để deploy, nên không có service mesh.
  • Chỉ có một người vận hành, nên sự đơn giản là tính năng an toàn, không phải thẩm mỹ.

Nếu hình dạng tải thật sự thay đổi, mọi quyết định trên đều đảo ngược được: bảng hàng đợi thành broker, báo cáo thành materialised view, monolith tách theo đúng ranh giới module đã có sẵn. Thứ không đảo ngược được là sự phức tạp bạn rước vào ngày đầu rồi phải vác theo trong lúc vẫn đang cố ship.

Hệ thống cũ tôi rà soát không thất bại vì chọn sai công cụ. Nó tích tụ bốn tầng máy móc quanh một cái index còn thiếu, rồi không gỡ nổi tầng nào. Tôi thà thêm một tầng về sau, có bằng chứng, còn hơn mất nhiều năm đi giải thích một tầng mà tôi chưa bao giờ cần.

Bài tiếp theo: cơ chế thu thập lõi — WebView cộng JavaScript tiêm vào, vì sao nó mong manh về bản chất, và biện pháp duy nhất khiến nó sống được.

Sản phẩm ở kidslen.app.

Xuất nội dung

Bình luận