Durable Objects và realtime
Khi nào thật sự cần, WebSocket với chế độ ngủ đông, và bẫy đã trả giá: kết nối chết câm vì đối tượng ngủ mà không ai biết.
Đây là bài dễ dùng nhầm nhất của Cấp 4. Durable Objects rất mạnh, và phần lớn app không cần tới nó.
Khi nào cần, khi nào không
Durable Objects giải đúng một bài toán: một thứ duy nhất, ở một chỗ duy nhất, có trí nhớ. Mỗi đối tượng có danh tính riêng, chỉ tồn tại một bản trên toàn thế giới, và giữ được trạng thái giữa các lượt gọi.
Cần Durable Objects khi:
- Nhiều người phải thấy cùng một thứ ngay lập tức — màn hình bếp của quán ăn, bảng đơn đang chờ, chat.
- Cần đếm chính xác tuyệt đối — tồn kho khi nhiều người cùng đặt, trần đăng nhập thật, số thứ tự.
- Cần khóa để hai người không cùng sửa một thứ.
Không cần, dù nghe có vẻ cần:
| Nhu cầu | Dùng cái rẻ hơn |
|---|---|
| "Danh sách tự cập nhật" | Tải lại khi người dùng quay lại tab, hoặc nút Làm mới |
| "Thông báo khi có đơn mới" | Hỏi lại mỗi 30–60 giây, chỉ khi tab đang hiển thị |
| "Nhiều người cùng xem báo cáo" | D1 bình thường — họ chỉ đọc |
| "Lưu phiên đăng nhập" | KV |
Phép thử thành thật: nếu dữ liệu trễ 30 giây thì có ai thiệt hại không? Không → đừng dùng Durable Objects. Thêm một đối tượng là thêm một thứ phải nuôi, phải gỡ lỗi, và phải trả tiền.
Đối tượng đầu tiên
// src/phong.js
export class PhongDonHang {
constructor(state, env) {
this.state = state; // bộ nhớ riêng, bền
this.env = env;
this.ketNoi = new Set();
}
async fetch(request) {
const url = new URL(request.url);
if (url.pathname.endsWith("/ws")) return this.moWebSocket(request);
if (url.pathname.endsWith("/them")) {
const don = await request.json();
const ds = (await this.state.storage.get("don")) || [];
ds.push({ ...don, luc: Date.now() });
await this.state.storage.put("don", ds.slice(-200)); // giữ 200 đơn gần nhất
this.phatTin({ kieu: "don-moi", don });
return new Response(JSON.stringify({ ok: true }));
}
const ds = (await this.state.storage.get("don")) || [];
return new Response(JSON.stringify(ds));
}
phatTin(obj) {
const s = JSON.stringify(obj);
for (const ws of this.state.getWebSockets()) {
try { ws.send(s); } catch (_) {}
}
}
}
Trong wrangler.toml:
[[durable_objects.bindings]]
name = "PHONG"
class_name = "PhongDonHang"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["PhongDonHang"]
Lệnh wrangler versions upload sẽ báo "migrations must be fully applied via a non-versioned deployment". Nghĩa là lần đầu có Durable Object, bạn phải wrangler deploy thẳng một lần cho migration ăn; từ lần sau mới quay lại quy trình preview → duyệt → deploy bình thường.
Biết trước điều này để khỏi hoảng khi quy trình an toàn của bạn đột nhiên bị từ chối.
Gọi từ Worker chính:
const id = env.PHONG.idFromName("quan-sam-son"); // tên ổn định = cùng một đối tượng
const phong = env.PHONG.get(id);
return phong.fetch(request);
idFromName("quan-sam-son") là điểm mấu chốt: cùng một tên luôn cho ra cùng một đối tượng, ở cùng một nơi. Mọi máy trong quán nói chuyện với đúng một đối tượng đó.
WebSocket với chế độ ngủ đông
Chế độ ngủ đông (hibernation) cho phép đối tượng ngủ khi không có việc mà vẫn giữ kết nối — bạn không trả tiền cho thời gian ngồi im. Đây gần như luôn là cách đúng.
async moWebSocket(request) {
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
// acceptWebSocket (KHÔNG phải server.accept()) mới cho phép ngủ đông
this.state.acceptWebSocket(server);
// Nhịp tim tự động: Cloudflare ping hộ, kết nối chết được dọn
this.state.setWebSocketAutoResponse(
new WebSocketRequestResponsePair("ping", "pong")
);
return new Response(null, { status: 101, webSocket: client });
}
// Khi ngủ đông, dùng handler này — KHÔNG dùng ws.addEventListener
async webSocketMessage(ws, msg) {
if (msg === "ping") return;
// xử lý tin nhắn
}
async webSocketClose(ws, code, reason, wasClean) {
// dọn dẹp nếu cần
}
Triệu chứng: app chạy tốt cả buổi, rồi im lặng ngừng cập nhật. Không lỗi, không thông báo, thanh trạng thái vẫn hiện "đã kết nối". Nhân viên tưởng không có đơn mới. Phát hiện ra khi khách gọi hỏi.
Nguyên nhân: đối tượng ngủ đông (hoặc mạng 4G đổi trạm), kết nối WebSocket đứt mà phía trình duyệt không biết. Sự kiện onclose không phải lúc nào cũng bắn.
Ba lớp phòng, cần đủ cả ba:
- Nhịp tim hai chiều. Phía máy chủ bật
setWebSocketAutoResponse. Phía trình duyệt gửipingmỗi 25 giây và đếm ngược chờpong; quá 10 giây không cópongthì tự coi là đứt và nối lại. - Nối lại có lùi dần. 1s, 2s, 4s, 8s, tối đa 30s, kèm nhiễu ngẫu nhiên — nếu không, mất mạng cả quán thì 20 máy cùng nối lại một lúc.
- Nối lại thì phải ĐỒNG BỘ LẠI toàn bộ, đừng chỉ nối. Trong lúc đứt đã có đơn mới mà bạn không nhận được.
let nhip, hanPong;
function noi() {
const ws = new WebSocket(url);
ws.onopen = () => {
dongBoLaiToanBo(); // (3) bắt buộc
nhip = setInterval(() => {
ws.send("ping");
clearTimeout(hanPong);
hanPong = setTimeout(() => ws.close(), 10000); // (1) không pong = đứt
}, 25000);
};
ws.onmessage = (e) => {
if (e.data === "pong") { clearTimeout(hanPong); return; }
xuLy(JSON.parse(e.data));
};
ws.onclose = () => { clearInterval(nhip); setTimeout(noi, cho()); }; // (2)
}
Đếm chính xác — việc mà D1 và KV không làm được
Bài toán: 10 người cùng đặt món cuối cùng trong kho. Với KV thì cả 10 đều đọc thấy "còn 1" và cả 10 đều đặt được.
Durable Object xử lý tuần tự từng yêu cầu, nên không có chuyện đó:
export class KhoHang {
constructor(state) { this.state = state; }
async fetch(request) {
const { monId, soLuong } = await request.json();
const ton = (await this.state.storage.get("ton:" + monId)) ?? 0;
if (ton < soLuong) {
return new Response(JSON.stringify({ ok: false, con: ton }), { status: 409 });
}
await this.state.storage.put("ton:" + monId, ton - soLuong);
return new Response(JSON.stringify({ ok: true, con: ton - soLuong }));
}
}
Cùng mô hình này dùng cho trần đăng nhập thật — xem Bảo mật & rate limit, nơi giải thích vì sao binding rate limit sẵn có của Cloudflare không đủ tin cậy cho cửa đăng nhập.
Trong app quán ăn, màn hình bếp cập nhật tức thì là tính năng khách nhìn thấy ngay trong buổi demo và gật đầu. Nó bán được hàng.
Nhưng giá trị vận hành thật lại nằm ở chỗ khác: nó xóa bỏ câu "đã nhận chưa?". Trước đó nhân viên phải chạy vào bếp hỏi. Sau đó, bếp bấm "đã nhận" là phía ngoài thấy ngay.
Bài học rút ra: khi cân nhắc realtime, đừng hỏi "có mượt hơn không" — hỏi "nó xóa được cuộc trao đổi nào giữa người với người?" Xóa được thì đáng làm. Không xóa được thì một nút Làm mới là đủ, và rẻ hơn nhiều.
Một app từng gọi /api/trang-thai mỗi 15 giây, trên mọi màn hình, kể cả màn hình không dùng dữ liệu đó. 30 máy × 4 lượt/phút × 10 giờ = 72.000 lượt mỗi ngày cho một thứ không ai nhìn.
Ba luật nếu buộc phải hỏi lặp:
1. Chỉ hỏi khi tab đang hiển thị (document.visibilityState === "visible").
2. Chỉ hỏi trên màn hình thật sự cần.
3. Giãn dần khi không có gì đổi: 15s → 30s → 60s, về lại 15s khi có thay đổi.
Chi phí và giới hạn
Durable Objects không có trong gói miễn phí — cần gói Workers Paid (5 USD/tháng, đã gồm hạn mức khá rộng). Tính theo số yêu cầu và thời gian đối tượng thức; nhờ ngủ đông, một phòng chat 20 người cả ngày vẫn rất rẻ.
Kiểm lại số liệu tại trang giá Cloudflare trước khi báo giá cho khách.
Thêm cập nhật tức thì cho màn hình "Đơn đang chờ".
- Durable Object tên PhongDon, định danh theo mã chi nhánh
(idFromName), lưu 200 đơn gần nhất.
- WebSocket dùng acceptWebSocket (chế độ ngủ đông), bật
setWebSocketAutoResponse ping/pong.
- Phía trình duyệt: gửi ping mỗi 25s, không nhận pong trong 10s
thì tự đóng và nối lại; nối lại lùi dần 1-2-4-8-30s có nhiễu
ngẫu nhiên; MỖI LẦN nối lại phải đồng bộ lại toàn bộ danh sách,
không chỉ nối suông.
- Khi WebSocket không dùng được: tự lùi về hỏi lại mỗi 30 giây,
CHỈ khi tab đang hiển thị. App phải dùng được bình thường ở
chế độ lùi này.
- Thanh trạng thái hiện đúng 3 trạng thái: đang kết nối / đã kết nối /
mất kết nối, dựa trên pong thật chứ không dựa trên sự kiện onopen.
Lưu ý: lần deploy đầu phải dùng `wrangler deploy` thẳng vì có migration.
Hai câu quan trọng nhất: đồng bộ lại khi nối lại và trạng thái dựa trên pong thật. Thiếu câu hai là bạn có một thanh trạng thái nói dối — tệ hơn là không có.
Lần đầu biết tới WebSocket, tôi muốn mọi màn hình đều tự cập nhật. Người kèm hỏi tôi đúng một câu: "Chị kế toán xem báo cáo tháng — số liệu trễ 30 giây thì sao?" Thì… chẳng sao cả.
Cuối cùng app của tôi chỉ có một màn hình realtime: đơn đang chờ. Đúng cái màn hình mà trước đây nhân viên phải chạy đi chạy lại hỏi nhau.
Tôi học được: realtime không phải thước đo hiện đại. Nó là công cụ cho đúng một loại vấn đề — nhiều người cần thấy cùng một thứ cùng lúc.
- Đủ: tiêu chí dùng/không dùng, đối tượng đầu tiên, WebSocket ngủ đông, nhịp tim ba lớp, đếm chính xác, chi phí.
- Đã trả giá thật: kết nối chết câm vì ngủ đông, hỏi lặp 15 giây cộng dồn, migration không qua được bản preview.
- Chưa nói: alarm (hẹn giờ trong đối tượng), SQL trong Durable Object, đồng bộ nhiều đối tượng. Phần lớn app không chạm tới.
- Rủi ro còn lại: realtime làm việc kiểm thử khó lên hẳn — phải test với hai thiết bị, test lúc mất mạng, test khi đối tượng vừa ngủ dậy. Nếu chưa đọc Test như kẻ phá hoại, đừng giao tính năng này cho khách.
- Dựng một màn hình realtime theo đề trên.
- Test với hai trình duyệt mở cùng lúc: bấm ở cửa sổ A, cửa sổ B phải đổi trong 1 giây.
- Test đứt mạng: tắt Wi-Fi 2 phút, trong lúc đó tạo đơn mới từ cửa sổ A (dùng mạng khác). Bật lại Wi-Fi ở cửa sổ B → phải tự nối lại và hiện đủ đơn đã bỏ lỡ. Đây là phép thử phân biệt realtime làm đúng với realtime làm cho có.
- Để app mở qua đêm, sáng kiểm còn cập nhật không. Nếu chết câm, nhịp tim của bạn chưa đúng.