// BLOG — 2026-09-17

Thử lại mà không nghĩ tới 'làm hai lần': chỗ hệ tự động hoá tự bắn vào chân mình

Đêm đó mình đi ngủ với cảm giác mọi thứ đang chạy đúng. Hàng đợi có việc, con máy tự đẩy từng việc một, màn hình log xanh mướt. Sáng hôm sau mở ra thì thấy ba thứ cùng lúc: một việc bị gửi…

# Thử lại mà không nghĩ tới "làm hai lần": chỗ hệ tự động hoá tự bắn vào chân mình

Đêm đó mình đi ngủ với cảm giác mọi thứ đang chạy đúng. Hàng đợi có việc, con máy tự đẩy từng việc một, màn hình log xanh mướt. Sáng hôm sau mở ra thì thấy ba thứ cùng lúc: một việc bị gửi hai lần, hai việc còn lại không bao giờ được gửi, và log — vẫn xanh. Không một dòng đỏ nào.

Đáng nói là mình mất gần một tiếng mới hình dung ra thứ tự sự việc đêm đó. Nền tảng bên kia bắt đầu trả lời bằng một câu rất lịch sự: mình đang gửi hơi dày, xin chậm lại một chút. Cái hệ của mình nghe thấy tín hiệu "chậm lại" ấy và diễn giải nó thành "thử lại đi". Nó thử lại ngay, không chờ, và thử thêm vài lần nữa cho chắc. Có lần nó thử trúng lúc việc đã thành công rồi, chỉ là phản hồi về muộn. Thế là hai lần.

Từ đêm đó mình hiểu ra một chuyện mà mình nghĩ rất nhiều anh em làm tự động hoá cũng đang hiểu sai: trong hệ tự động hoá, chỗ nguy hiểm nhất thường không nằm ở chỗ mình viết, mà ở chỗ mình không sở hữu.

[QUOTE — câu mình tự viết, dán cạnh màn hình:]

Thử lại là một tính năng. Làm-hai-lần là một hoá đơn. Và cả hai đều được kích hoạt bằng cùng một dòng code.


Mắt xích duy nhất mình không sở hữu

Mình tự viết được prompt. Tự chọn được mức model. Tự dựng được hàng đợi, tự host, tự sửa, tự gỡ. Nhưng có đúng một mắt xích trong chuỗi mà mình không phải chủ, chỉ là khách: API của bên thứ ba. Nó đổi giá khi nào cũng được, đổi cách trả lỗi khi nào cũng được, đổi định nghĩa "lỗi tạm thời" khi nào cũng được, và nó không gửi thư xin phép mình.

Đây là lý do mình thấy các hệ tự động hoá hay vỡ theo cùng một kiểu. Phần mình viết ra thì mình kiểm soát được gần hết: input sai thì mình thấy, code sai thì mình thấy. Còn phần bên kia trả về thì mình chỉ có thể chuẩn bị. Và chuẩn bị cho nó là một tầng riêng, không phải vài dòng try/except dán thêm cho có.

Nói cách khác: mình không điều khiển được bên kia có khó chịu với mình hay không. Mình chỉ điều khiển được thái độ của mình khi họ khó chịu.


Giới hạn tần suất không phải là lỗi. Nó là một lịch trình.

Đây là câu mình muốn anh em mang đi nếu chỉ đọc được một đoạn của bài này.

Khi gọi ra bên ngoài, thực ra chỉ có ba loại phản hồi, chứ không phải hai:

Một — xong rồi. Bình thường, không cần nghĩ.

Hai — hỏng chắc chắn. Sai tham số, hết quyền, hết tiền, dữ liệu vô nghĩa. Loại này thử lại bao nhiêu lần cũng chỉ tốn tiền và làm log rối. Nó cần được sửa, không phải được thử lại.

Ba — chưa biết. Và đây là loại mình từng gộp bừa vào nhóm hai. Quá tải, bị chặn vì gửi dày, hoặc tệ nhất: hết thời gian chờ. Hết thời gian chờ không có nghĩa là việc đã thất bại — nó có nghĩa là mình không biết việc đã xảy ra hay chưa. Bên kia có thể đã ghi nhận xong và chỉ có phản hồi quay về muộn.

Cái nhóm thứ ba này mới là chỗ sinh ra mọi rắc rối. Giới hạn tần suất nằm trong nhóm ba, nhưng nó là loại lành tính nhất: nó là một lời nói "đợi chút", không phải "đừng làm nữa". Một lời nói "đợi chút" mà bị đọc thành "làm lại ngay" thì mình đang tự biến việc bị chặn thành việc bị chặn lâu hơn.

Ba nhóm này khác nhau hoàn toàn về cách xử lý, và mình coi việc tách chúng ra là tầng số 0 của mọi thứ chịu lỗi. Không tách thì mọi thứ phía sau mình làm — hàng đợi, số lần thử, cảnh báo — đều dựng trên một giả định sai.


Thử lại là con dao hai lưỡi

Giờ tới phần mình nghĩ ít người nói rõ: thử lại làm hệ bền hơn, và cũng làm hệ phá hoại hơn. Hai chuyện đó đi cùng nhau, không tách được.

Nhìn theo hai hướng:

Với việc chỉ đọc — tra dữ liệu, lấy trạng thái, hỏi model một câu không ghi gì — thử lại gần như miễn phí về mặt hậu quả. Thử hai lần thì vẫn là một câu trả lời.

Với việc có ghi — gửi một email, đẩy một bài, tạo một đơn, cập nhật một trạng thái cho khách — thử lại là một hành động có hệ quả kép. Và vì nhóm ba ở trên không nói cho mình biết việc đã xảy ra chưa, "thử lại" thực chất là cược: cược rằng lần trước nó chưa thành công. Cược sai một lần là gửi hai lần cho khách.

Nên câu hỏi đúng không phải "có nên thử lại không", mà là: việc này có chạy lại được mà không gây hậu quả thứ hai hay không? Người ta gọi tính chất đó là idempotent — làm lại lần nữa mà kết quả vẫn chỉ là một. Nghe như thuật ngữ, nhưng cách làm thì rất đời: mình gắn cho mỗi việc một cái khoá định danh, và gửi kèm cái khoá đó trong mọi lần thử. Bên nhận thấy cùng một khoá thì biết đây là cùng một việc, không phải việc mới.

Bài học mình trả giá để hiểu: việc không có khoá định danh thì không được phép thử lại tự động. Không có ngoại lệ.


Bốn tầng mình dựng lại sau đêm đó

Mình dựng lại theo đúng thứ tự này, và tới giờ nó vẫn là khung mình dùng cho mọi pipeline có gọi bên ngoài.

Tầng 1 — phân loại phản hồi trước khi làm bất cứ thứ gì. Mỗi lần gọi ra ngoài phải trả về một trong ba nhãn ở trên, chứ không phải một cục "lỗi". Phần này không cần AI, không cần thư viện gì: nó là mấy dòng if. Nhưng chính nó quyết định phần còn lại của hệ đúng hay sai.

Tầng 2 — ghi ý định trước khi gọi, không ghi sau khi xong. Trước khi chạm vào bên ngoài, mình đánh dấu việc đó là "đang làm" và sinh khoá định danh, lưu xuống trước. Cái này gọi là ghi trước — nó khiến việc "đang làm dở" trở thành một trạng thái có thật, thay vì một khoảng trống chỉ tồn tại trong đầu mình. Hệ có sập giữa lúc gọi, lần chạy sau vẫn biết chính xác mình đang ở đâu.

Tầng 3 — hàng đợi với khoảng chờ tăng dần, có trần, và có nhiễu. Ba thứ trong câu này đều quan trọng:

  • ▹Tăng dần: lần một chờ vài giây, lần hai chờ gấp đôi, cứ thế. Bị chặn vì gửi dày thì gửi dày hơn là tự sát.
  • ▹Có trần: chờ tới một mức nào đó thì thôi. Không có trần thì mình có một vòng lặp chỉ có thể kết thúc bằng việc quota cháy sạch lúc 3 giờ sáng.
  • ▹Có nhiễu ngẫu nhiên: đây là chi tiết nhỏ mà mình thấy ít người để ý. Nếu mọi việc cùng bị chặn và cùng chờ đúng 5 giây rồi cùng quay lại, mình vừa tạo ra một đợt sóng mới đúng bằng khoảng thời gian đó — tự tay tái tạo lại cái giới hạn vừa bị chặn. Thêm một chút ngẫu nhiên là cách rẻ nhất để phá cái sóng đó.

Tầng 4 — chỗ để bỏ, và một cổng cho người. Sau khi hết số lần thử, việc đó không được biến mất. Nó phải rơi vào một danh sách riêng, kèm lý do cụ thể, và chờ người xem. Đây là chỗ mình nối vào bài học cũ của mình: một hệ tự động hoá không có tầng "người quyết" ở cuối thì bắt buộc phải chọn giữa hai thứ tệ — hoặc bỏ việc trong im lặng, hoặc thử lại tới vô hạn.

Mình hay minh hoạ bằng một hàm ngắn. Pseudo-code, không phải nguồn triển khai thật của mình:

# Pseudo-code minh hoạ — KHÔNG phải nguồn triển khai
def xu_ly(viec):
    if da_hoan_tat(viec.id):            # làm lại mà không làm hai lần
        return "bỏ qua — việc đã xong"

    khoa = sinh_khoa_dinh_danh(viec.id)
    ghi_y_dinh(viec.id, "dang_lam", khoa)   # ghi TRƯỚC khi gọi bên ngoài

    kq = goi_ben_ngoai(viec, khoa)

    if kq.loai == "hong_chac_chan":     # sai input, hết quyền
        day_vao_cho_nguoi(viec, ly_do=kq)
        return "cần sửa, không thử lại"

    if kq.loai == "chua_biet":          # hết thời gian chờ — chưa rõ đã xảy ra chưa
        if viec.lap_lai_an_toan:        # chỉ đọc -> thử lại được
            hen_lai(cho_co_tran(viec.so_lan_thu) + nhieu_ngau_nhien())
            return "chờ"
        day_vao_cho_nguoi(viec, ly_do="có thể đã thực hiện — cần xác minh")
        return "chuyển cho người"

    if kq.loai == "qua_tai":            # bị chặn vì gửi dày
        if viec.so_lan_thu >= viec.tran:
            day_vao_cho_nguoi(viec, ly_do=kq)
            return "hết lượt thử"
        hen_lai(cho_co_tran(viec.so_lan_thu) + nhieu_ngau_nhien())
        return "chờ"

    danh_dau_hoan_tat(viec.id)

Đọc lại thì thấy phần khó nhất của cả hàm này không phải mấy dòng if, mà là hai chỗ: dòng ghi ý định trước khi gọi, và nhánh chua_biet. Hai chỗ đó là hai chỗ duy nhất mà mình từng bỏ qua, và cũng là hai chỗ đã trả giá.

Ngoài bốn tầng trên, mình thêm một cái cầu dao: khi tỷ lệ lỗi vượt một ngưỡng trong một khoảng thời gian, hệ dừng việc mới thay vì tiếp tục đẩy. Lý do rất thực dụng: mỗi lần thử đều có thể là tiền. Một hệ lỗi mà vẫn tiếp tục thử là một hệ đang tự đốt ngân sách để chứng minh mình đang lỗi.


Vì sao chuyện này là chuyện tiền, không phải chuyện kỹ thuật

Kỹ thuật thì anh em nào làm cũng thấy. Mình muốn nói phần ít được nói tới hơn.

Một, gửi hai lần là lỗi không sửa được bằng lời xin lỗi. Một email trùng, một đơn trùng, một bài đăng trùng — khách không thấy "hệ thống của anh bị giới hạn tần suất". Họ chỉ thấy một nhà cung cấp cẩu thả. Và cái ấn tượng đó không xoá được bằng câu giải thích kỹ thuật, dù câu giải thích đúng.

Hai, thứ khách trả tiền cho mình thật ra không phải sự thông minh. Khi mình bán một dịch vụ chạy đều mỗi ngày, phần lớn khách không trả tiền vì hệ thông minh. Họ trả tiền vì hệ không quên. Một việc chạy 95% thời gian nghe thì tốt, nhưng cái 5% còn lại rơi vào đúng lúc khách cần nhất thì con số 95% không cứu được ai. Và điều thú vị: phần chịu lỗi trong hệ của mình thường không tốn thêm token nào. Nó là logic thuần, chạy gần như miễn phí — trong khi nó lại là thứ bảo vệ toàn bộ phần đắt tiền ở phía sau.

Ba, thử lại là một dòng tiền nếu mình không đặt trần. Nếu chi phí tính theo lượt gọi, mỗi lần thử là một lần trả tiền. Mình từng có một khoảng thời gian mà phần lớn chi phí trong ngày đến từ những lần thử lỗi, chứ không phải từ công việc thật. Trần số lần thử không phải là chi tiết kỹ thuật — nó là một con số kế toán.

Bốn, ba con số mình theo dõi từ đó tới nay. Tỷ lệ việc phải xử lý tay. Thời gian chờ dài nhất của việc bị chặn. Số việc đang nằm ở chỗ chờ người xem. Con số đầu tăng lên là dấu hiệu rõ nhất cho thấy hệ không còn đang kiếm tiền cho mình — nó đang tạo việc cho mình. Mình từng rất thích log toàn màu xanh. Tới giờ mình vẫn thích, chỉ là mình không còn tin nó nữa: log xanh có thể có nghĩa là "mọi việc suôn sẻ", mà cũng có thể có nghĩa là "hai việc đã biến mất không ai nhắc".


Bốn cái bẫy, để anh em khỏi trả giá như mình

Thử lại ngay lập tức. Bị chặn vì gửi dày, rồi gửi lại ngay trong cùng một giây — cái này mình làm đúng vào đêm kể ở đầu bài. Không có khoảng chờ thì việc thử lại chỉ làm bên kia thấy mình dày hơn, và chặn lâu hơn.

Thử lại không có trần. Hệ quả không phải là "chạy lâu", mà là "chạy mãi". Sáng dậy thấy log đầy những lần thử của một việc duy nhất, và một khoảng quota trống không.

Bỏ việc trong im lặng. Đây là cái bẫy nguy hiểm nhất và mình đã viết nhiều lần ở các bài trước: một hệ bỏ việc mà không báo thì tệ hơn một hệ báo lỗi ồn ào. Lỗi đỏ thì mình sửa. Lỗi im lặng thì mình không biết nó tồn tại cho tới khi khách hỏi.

Thử lại việc có ghi mà không có khoá định danh. Nếu anh em chỉ nhớ một câu của bài này, nhớ câu đó: chỉ thử lại tự động những việc mà lần thứ hai vẫn cho ra kết quả y như lần thứ nhất. Còn lại thì để người xem.


Cái mình giữ lại

Mình vẫn nghĩ tự động hoá là một trong những cách kiếm tiền lành mạnh nhất mà một người làm một mình có thể sở hữu: chi phí thấp, không cần xin phép ai, và có thể dựng ngay tối nay. Nhưng nó có một cái giá ngầm mà người mới hay bỏ qua: mình chỉ đánh giá được hệ của mình vào đúng cái ngày mọi thứ không suôn sẻ.

Ngày bên thứ ba khoá cửa, ngày mạng nhà mình chập, ngày model trả về chậm hơn bình thường gấp mười lần. Đó là ngày mình biết hệ của mình đang kiếm tiền cho mình, hay đang chờ mình sáng mai dậy sửa.

Nên mình để lại một câu hỏi duy nhất cho anh em, và nó là câu hỏi mình tự hỏi mình mỗi lần dựng một mắt xích mới:

Nếu API bên thứ ba chặn anh em suốt hai tiếng đồng hồ tối nay, hệ của anh em sẽ làm gì? Chờ, gửi hai lần, hay bỏ việc trong im lặng? Nếu anh em chưa trả lời được ngay, thì tầng đó chưa tồn tại — và cái đêm mình kể ở đầu bài chỉ là chuyện sớm muộn.

Một phần của chuỗi mổ xẻ kỹ thuật tại zeodavu.com/blog. Nếu thấy hữu ích, theo dõi [zeodavu.com](/blog) — mỗi bài là một thứ mình thật sự dùng, vấp, và sửa lại.