Skip to content

Bài toán kỹ thuật khó nhất

Câu hỏi — "Bài toán kỹ thuật khó nhất mà bạn từng giải quyết là gì?" Các biến thể: "Hãy kể về một tình huống thử thách về mặt kỹ thuật mà bạn từng đối mặt.", "Mô tả một lần bạn phải giải quyết một bài toán kỹ thuật phức tạp."

Điều họ đang đánh giá — Chiều sâu kỹ thuật và sự am hiểu lĩnh vực; cách tiếp cận bài bản, dựa trên giả thuyết khi mọi thứ còn mơ hồ; sự kiên trì lúc chưa nhìn ra lối đi; vai trò cá nhân rõ ràng và tác động đo lường được.

Khung STAR-L

Điền câu chuyện của riêng bạn:

  • Tình huống — Hệ thống, dịch vụ hay codebase nào có liên quan? Cái gì đã hỏng, hoặc ràng buộc nào khiến bài toán khó? Hãy cho đủ bối cảnh kỹ thuật để một kỹ sư đồng cấp hiểu được nó khó cỡ nào.
  • Nhiệm vụ — Cụ thể bạn phải xử lý cái gì? Có deadline hay có đụng đến production không?
  • Hành động — Kể lại cách bạn làm theo từng bước: Bạn đặt giả thuyết ra sao? Bạn dùng công cụ hay kỹ thuật nào để khoanh vùng vấn đề? Bạn đâm vào ngõ cụt nào và xoay hướng thế nào? Hãy nói "tôi" và nêu rõ từng bước kỹ thuật (ví dụ: "tôi thêm distributed tracing và phát hiện đỉnh tải nằm ở khâu serialization chứ không phải ở truy vấn DB").
  • Kết quả — Kết quả đo được là gì? (latency, tỷ lệ lỗi, uptime, throughput, chi phí — hãy đưa con số cụ thể.)
  • Bài học — Bạn rút ra được mô hình tư duy, cách debug, hay lối làm việc nào? Giờ bạn áp dụng nó ra sao?

Cạm bẫy / dấu hiệu cảnh báo

  • Sa đà vào thuật ngữ chuyên môn mà không nói rõ vì sao bài toán thực sự khó.
  • Vai trò cá nhân mờ nhạt — "rồi nhóm cũng tìm ra cách" mà chẳng có hành động "tôi" nào.
  • Chọn một bài toán không đến mức khó (một chỉnh cấu hình đơn giản hay một bug tìm ra trong 10 phút).
  • Không có kết quả đo được — "sau đó nó chạy tốt hơn".
  • Bỏ qua phần làm thế nào — chỉ kể bản vá cuối cùng mà không cho thấy quá trình suy luận, điều tra.
Ví dụ 1 — Chiến lược caching cho một indexer biểu đồ crypto

Tình huống: Tôi xây indexer đứng sau các biểu đồ nến của bên tôi. Nó đọc giao dịch thô từ một stream và phục vụ nến OHLCV ở nhiều độ phân giải (1m, 5m, 1h, 1d) cho hàng trăm cặp. Biểu đồ là endpoint bị gọi nhiều nhất, mà cứ mỗi request lại dựng lại nến từ giao dịch thô khiến database đội lên 100% CPU và biểu đồ mất 3–5s mới tải xong.

Nhiệm vụ: Tôi phụ trách làm cho biểu đồ nhanh mà vẫn giữ dữ liệu chính xác khi giao dịch mới liên tục đổ về — nến mới nhất thì luôn thay đổi, còn nến cũ thì không bao giờ đổi nữa.

Hành động: Tôi bám vào đúng một điểm mấu chốt: nến cũ đã cố định, chỉ nến hiện tại là còn động. Tôi tính trước và lưu sẵn các nến đã đóng, rồi dựng các độ phân giải lớn hơn bằng cách cuộn (roll up) từ độ phân giải nhỏ hơn thay vì quét lại giao dịch thô. Tôi cache theo key (pair, resolution, bucket) thành hai tầng: nến đã đóng thì cache vĩnh viễn (vì chúng không bao giờ đổi), còn đúng một nến đang mở thì cache ngắn hạn và cập nhật mỗi khi giao dịch đổ về. Nhờ vậy mỗi biểu đồ chỉ có duy nhất một key "nóng", nên tôi khỏi cần cơ chế invalidation rắc rối. Trước khi triển khai, tôi load-test bằng cách phát lại traffic production.

Kết quả: p95 thời gian tải biểu đồ giảm từ ~3.5s xuống dưới 120ms, CPU database cho biểu đồ giảm ~90%. Tỷ lệ cache hit trên dữ liệu lịch sử đạt trên 99%, và thêm một độ phân giải mới gần như không tốn gì vì nó cuộn lên từ những cái đã có.

Bài học: Thắng lợi đến từ việc tách phần dữ liệu không bao giờ đổi ra khỏi phần nhỏ có thay đổi — nhờ đó một bài toán caching khó biến thành bài toán dễ. Giờ hễ cache dữ liệu time-series hay dữ liệu chỉ ghi thêm (append-only) là tôi lại nghĩ đến cách tách này.

Ví dụ 2 — Retry pattern cho tính năng tip crypto qua Discord slash command

Tình huống: Người dùng có thể tip và gửi crypto cho nhau bằng Discord slash command. Đây là tiền thật chạy qua một đường truyền chập chờn: Discord hủy interaction nếu bạn không phản hồi trong 3 giây và client có thể retry, đường kết nối của bên tôi tới node blockchain thì phập phù, còn người dùng nóng ruột thì gõ /tip hai lần. Làm ngây thơ là gửi tiền hai lần như chơi.

Nhiệm vụ: Tôi phụ trách luồng chuyển tiền và phải đảm bảo một cú tip chỉ chạy đúng một lần — dù có retry, timeout hay lỗi giữa chừng — mà vẫn phản hồi kịp trong giới hạn 3 giây của Discord.

Hành động: Tôi làm cho cả luồng trở nên idempotent. Đầu tiên tôi gửi Discord một ack "đang xử lý…" ngay để kịp trong 3 giây. Sau đó tôi lưu giao dịch chuyển tiền dưới một idempotency key sinh từ (interaction_id, sender, recipient, amount). Việc trừ tiền thật sự chạy như một bước riêng, được key đó canh gác: một lần retry sẽ khớp vào bản ghi có sẵn và trả về kết quả gốc thay vì trừ tiền lần nữa. Với lệnh gọi lên chain, tôi retry kèm backoff và jitter, nhưng chỉ với lỗi có thể retry như timeout — tuyệt đối không retry một lỗi thực sự như số dư không đủ. Cái gì còn lỗi thì giữ nguyên trạng thái pending, và một job reconciliation có thể hoàn tất nó sau đó một cách an toàn vì mọi bước đều đã gắn key.

Kết quả: Số lần gửi trùng về không. Giao dịch tự vượt qua được retry của Discord lẫn những cú chập chờn của node, còn job reconciliation tự động dọn các dòng pending bị kẹt. Người dùng thấy một thông báo nhanh "đang xử lý…" thay vì lỗi timeout.

Bài học: Retry chỉ an toàn khi thao tác đã idempotent — key mới là bản vá thực sự, còn backoff chỉ là phép lịch sự. Giờ với bất kỳ lệnh gọi nào có dịch chuyển tiền, tôi đều thiết kế theo nguyên tắc "ack thật nhanh, gắn key cho công việc, chỉ retry những gì an toàn để retry".

Ví dụ 3 — Giữ dữ liệu on-chain và off-chain nhất quán cho việc claim phần thưởng token

Tình huống: Tôi làm một game blockchain mà một streamer Twitter có thể chơi trực tiếp cho khán giả — nhiều người xem vào cùng một phiên, chơi, và khi phiên kết thúc mỗi người thắng claim một phần thưởng token. Phần thưởng được trả từ một vault duy nhất có số dư cố định, nên tổng các claim trong một phiên phải nằm gọn trong số vault đang giữ — không bao giờ được trả nhiều hơn số đã cấp vốn. Người chơi là tài khoản off-chain, nhưng việc claim lại diễn ra on-chain, nên bên tôi ánh xạ mỗi người chơi tới địa chỉ ví của họ và lưu một claim log trong Postgres, đánh key theo địa chỉ đó. Chỗ khó nằm ở hai nguồn sự thật: số dư thật thì nằm on-chain trong vault, còn điều kiện đủ và claim log thì bên tôi theo dõi trong Postgres. Một lần claim đụng vào cả hai — ghi log trong DB dịch chuyển token on-chain — mà hai lệnh ghi đó lại không thể gói chung trong một transaction. Nếu lệnh gọi lên chain thành công nhưng lệnh ghi DB thất bại (hoặc ngược lại), hai bên vênh nhau: hoặc một người chơi claim trùng, hoặc log ghi một khoản chi mà thực ra chưa hề rời khỏi vault.

Nhiệm vụ: Tôi phụ trách luồng claim và phải giữ claim log trong DB khớp với vault on-chain — không claim trùng, không rút vault cố định vượt quá số dư — bất chấp transaction thất bại, retry, và cả một đám khán giả cùng bấm claim đúng lúc phiên kết thúc.

Hành động: Tôi thôi coi DB và chain là hai bên ghi ngang hàng, mà lấy chain làm nguồn sự thật. Một lần claim trước tiên ghi một dòng pending vào claim log dưới idempotency key (session_id, wallet_address), rồi mới gửi transaction on-chain vào vault. Tôi không bao giờ đánh dấu claim là settled ngay lúc gửi — chỉ khi một indexer xác nhận transaction đã finalize on-chain thì mới khớp nó ngược lại với dòng pending qua key đó. Nếu một claim đứt gánh giữa chừng, nó nằm lại ở pending, và một job reconciliation sẽ dò lại chain rồi settle hoặc release nó, nhờ đó không phần thưởng nào bị ghi hai lần hay bị mất. Để chặn claim trùng từ cùng một ví, tôi cho đổi trạng thái theo điều kiện — UPDATE claims SET status = 'pending' WHERE session_id = :s AND wallet_address = :w AND status = 'unclaimed' — nên với mỗi ví trong mỗi phiên chỉ request đầu tiên mới đi tiếp được. Và vì vault có số dư cố định, tôi kiểm tra phần phân bổ còn lại của phiên trước khi gửi, nên các claim đồng thời không thể cùng nhau rút vượt.

Kết quả: DB và trạng thái on-chain thôi lệch nhau — một phép reconciliation vốn hay bắt được sai lệch nay trả về sạch. Số lần claim trùng về không, vault không bao giờ trả quá số nó giữ, và các claim tự vượt qua được transaction thất bại lẫn retry mà chẳng cần sửa tay. Ngay cả khi cả một đám khán giả cùng claim một lúc, mỗi người chơi vẫn nhận được phản hồi nhanh "đã gửi claim" trong lúc chain xác nhận ở nền.

Bài học: Bạn không thể gói một lệnh ghi database và một lệnh gọi blockchain vào chung một transaction, nên tôi chọn lấy một nguồn sự thật (chain), cho DB bám theo nó, rồi dùng idempotency key cộng một job reconciliation để lấp khoảng trống. Giờ với bất kỳ lệnh ghi xuyên hệ thống nào, tôi đều thiết kế theo nguyên tắc "một nguồn sự thật + xác nhận bất đồng bộ + reconcile", chứ không bao giờ ghi hai nơi rồi vờ như nó atomic. Xem thêm delivery semanticseventual consistency.

Ví dụ 4 — Load test cơn bão claim bằng k6 trong CI

Tình huống: Cũng trong game streaming đó, dạng traffic rất khắc nghiệt: một phiên có thể im ắng suốt mười phút, rồi đúng khoảnh khắc nó kết thúc, cả một đám khán giả đồng loạt bấm "claim" trong vài giây. Đỉnh tải đó chính là lúc luồng claim — các lệnh ghi DB, phép kiểm tra phân bổ vault, và các lệnh gửi on-chain — dễ sụp nhất, mà bên tôi lại chỉ phát hiện ra ngay giữa một buổi stream trực tiếp, đúng thời điểm tệ nhất.

Nhiệm vụ: Tôi phụ trách việc kiểm thử một cơn bão claim trước khi nó ra tới production, để một phiên lớn không thể làm sập game trước mặt khán giả trực tiếp.

Hành động: Tôi viết load test bằng k6 mô phỏng đúng dạng traffic thực chứ không phải traffic đều đều — một đoạn ramp giữ phẳng, rồi một cú bùng nổ dữ dội hàng trăm claim đồng thời để giả lập lúc phiên kết thúc. Mỗi virtual user claim bằng một ví riêng để tôi thử đúng các đường xử lý per-wallet thật, chứ không phải một dòng nóng duy nhất. Tôi đặt threshold lên kết quả — p95 latency và tỷ lệ lỗi phải nằm trong giới hạn đã thống nhất — để bài test tự fail nếu có regression, khỏi cần ai ngồi soi biểu đồ. Rồi tôi cắm nó vào pipeline GitHub Actions: mỗi pull request đụng vào đường claim là k6 chạy nhắm vào một môi trường test tạm, hễ vượt threshold là fail check và chặn merge.

Kết quả: Bên tôi bắt được hai regression ngay trong CI trước khi chúng lên production — một cái là phép kiểm tra phân bổ vault serialize các claim khi tải cao, một cái là giới hạn connection-pool sụp khi vượt vài trăm người dùng đồng thời. Nhờ threshold của k6 canh gác việc merge, hiệu năng thôi là thứ để hy vọng mà thành một check pass/fail hẳn hoi. Đến khi các phiên thật kết thúc, cơn bão claim vẫn trụ vững.

Bài học: Load test chỉ có ích khi nó mô phỏng đúng dạng traffic thực — một đoạn ramp đều đều hẳn đã bỏ sót đúng cú bùng nổ đã làm bên tôi lãnh đủ. Và đưa bài test vào CI kèm threshold cứng mới là thứ khiến nó bám trụ; một benchmark không ai chạy rồi cũng mục ruỗng. Giờ tôi viết load test xoay quanh cú đỉnh tải tệ nhất mà thực tế có thể xảy ra, rồi lấy nó canh gác pipeline. Xem thêm rate limiter.

Ví dụ 5 — Một circuit breaker để chặn lỗi lan truyền trong một dịch vụ maintenance

Tình huống: Tôi xây một dịch vụ maintenance trong hệ thống microservice của bên tôi — nó chạy các job nền gọi sang nhiều dịch vụ khác để đồng bộ trạng thái, dọn dẹp, và kiểm tra health. Một ngày nọ, đúng một dịch vụ downstream trở nên chậm (không sập, chỉ chậm — phản hồi bò từ 50ms lên 20s). Các worker maintenance cứ gọi nó rồi bị chặn (block) trên đám phản hồi chậm đó, nên chúng chất đống chờ đợi thay vì làm xong việc. Chỉ vài phút sau, mọi worker đều kẹt vào đúng một dependency đó, cả dịch vụ maintenance ngừng làm các job khác, và backpressure bắt đầu lan ngược lên các caller phía trên. Một dịch vụ chậm đang kéo sập cả một chuỗi dịch vụ vốn khỏe mạnh.

Nhiệm vụ: Tôi phụ trách làm cho dịch vụ maintenance chịu tải được — một dependency ốm chỉ nên làm chậm đúng phần công việc cần đến nó, chứ không đóng băng cả dịch vụ.

Hành động: Tôi bọc các lệnh gọi tới mỗi dịch vụ downstream trong một circuit breaker. Nó theo dõi tỷ lệ lỗi và timeout gần đây của từng dependency, hễ tỷ lệ đó vượt ngưỡng là nó "mở" — trong một khoảng cooldown, mọi lệnh gọi tới dependency đó fail nhanh thay vì chặn một worker suốt 20 giây. Hết cooldown, nó chuyển sang "half-open" và cho vài lệnh gọi thử đi qua; thành công thì nó đóng lại và nối lại traffic bình thường, còn không thì nó vẫn mở. Tôi kết hợp với timeout chặt (để không lệnh gọi nào treo được một worker) và một đường fallback để những job không thực sự cần dependency đó vẫn chạy xong. Tôi để ngưỡng và cooldown có thể cấu hình được, nhờ vậy tinh chỉnh theo từng dependency chứ không đoán bừa một lần cho xong.

Kết quả: Một dependency chậm thôi đóng băng được cả dịch vụ — worker fail nhanh trên đường đang hỏng rồi tiếp tục làm mọi việc khác. Bán kính ảnh hưởng thu từ "cả dịch vụ maintenance sập" xuống còn "một loại job bị trễ", và dịch vụ tự hồi phục ngay khi dependency lành lại, khỏi cần khởi động lại tay. Bên tôi còn có thêm tín hiệu rõ ràng — một breaker đang mở là lời cảnh báo chính xác về việc dependency nào đang có vấn đề.

Bài học: Trong hệ microservice, gọi thẳng một dependency là thừa hưởng luôn cả lỗi của nó; một circuit breaker biến một lỗi chậm, lan lây thành một lỗi nhanh và gói gọn được. Timeout, fail-fast, cộng một phép thăm dò phục hồi — ba thứ đó hợp lại mới giữ cho một dịch vụ hỏng không kéo sập phần còn lại. Giờ hễ có lệnh gọi liên dịch vụ nào nằm trên đường tới hạn là tôi đặt một breaker (kèm một timeout đàng hoàng) lên đó.