Network Programming · Part 3 — UDP & Datagrams: Fast and Connectionless
UDP for Node.js + TypeScript: connectionless datagrams with node:dgram, when UDP beats TCP, the 65507-byte limit, no backpressure, and beginner pitfalls — bilingual, with runnable examples.
Đây là Phần 3 của series 10 bài về lập trình mạng với Node.js + TypeScript. Ở Phần 2 ta xây server TCP — tin cậy, có thứ tự, theo hướng kết nối. Giờ ta gặp đối lập: UDP — nhanh, không kết nối, và thẳng thắn về những gì nó không đảm bảo.
UDP là cách internet làm tra DNS, state game real-time, VoIP, và luồng metrics. Nếu app chấp nhận mất một chút để đổi lấy độ trễ thấp, UDP thường là công cụ đúng.
UDP là gì?
UDP (User Datagram Protocol) nằm ở tầng Transport — cùng tầng với TCP, nhưng hợp đồng khác.
| Property | UDP behavior |
|---|---|
| Connection | Không kết nối — không bắt tay, không trạng thái phiên |
| Delivery | Cố gắng hết sức — gói có thể mất |
| Ordering | Không đảm bảo — có thể sai thứ tự |
| Data model | Theo message — một send() = một datagram, một lần nhận = một message nguyên vẹn |
| Flow control | Không có — không backpressure, không cửa sổ |
Ý chính: TCP cho bạn luồng byte tin cậy; UDP cho bạn datagram độc lập và để việc tin cậy cho ứng dụng.
Dòng cuối — theo message — là điểm người mới hay bỏ qua. Với TCP, hai lần write('HELLO') có thể gộp thành một mảnh hoặc tách thành ba lần đọc. Với UDP, mỗi datagram giữ ranh giới — bạn gửi gì thì receiver nhận đúng vậy trong một sự kiện message.
Bắn xong quên: không bắt tay
TCP tốn một vòng bắt tay ba bước trước khi dữ liệu chảy. UDP bỏ qua hoàn toàn.
Sender gọi send() kèm địa chỉ đích mỗi lần; socket receiver bắn 'message' cho mỗi datagram đến. Không có nghi thức connect(), không end(), không trạng thái half-open để debug.
Khi nào UDP thắng
Chọn UDP khi tốc độ và đơn giản quan trọng hơn mọi byte đến đúng thứ tự:
- câu hỏi/trả lời nhỏ, retry ở tầng app nếu cần.
- vị trí player mới nhất quan trọng hơn bản “xác nhận” nhưng cũ.
- bỏ một frame, tiếp tục nói; TCP sẽ làm cả luồng đứng.
- mất 1 % mẫu chấp nhận được; block thì không.
- thăm dò “ai trên LAN này?” không cần kết nối bền.
Nếu cần giao hàng đảm bảo, thứ tự, hoặc kiểm soát tắc nghẽn, ở lại TCP (hoặc tự thêm lớp tin cậy trên UDP, như QUIC).
Server UDP tối giản
Node expose UDP qua module node:dgram. Server bind vào port và lắng nghe datagram:
import { createSocket } from 'node:dgram';
const PORT = 41234;
const server = createSocket('udp4');
server.on('message', (msg, rinfo) => {
console.log(`got ${msg.length} bytes from ${rinfo.address}:${rinfo.port}`);
console.log('payload:', msg.toString('utf8'));
});
server.on('error', (err) => {
console.error('server error:', err);
server.close();
});
server.bind(PORT, () => {
console.log(`UDP server listening on ${PORT}`);
});
Điểm API quan trọng:
- socket UDP IPv4; dùng
'udp6'cho IPv6. - chiếm port, giống
listen()trong TCP. - handler nhận
(msg, rinfo)—msglà một datagram hoàn chỉnh;rinfocóaddress,port,size.
Client UDP tối giản
Client không “kết nối” — chỉ gửi datagram tới một địa chỉ:
import { createSocket } from 'node:dgram';
const PORT = 41234;
const HOST = '127.0.0.1';
const client = createSocket('udp4');
const message = Buffer.from('ping');
client.send(message, PORT, HOST, (err) => {
if (err) {
console.error('send failed:', err);
client.close();
return;
}
console.log(`sent "${message.toString()}" to ${HOST}:${PORT}`);
client.close();
});
send(buffer, port, host, callback) gửi một datagram. Bạn phải truyền port và host mỗi lần gửi — không có peer ngầm trừ khi gọi socket.connect() (tiện ích tùy chọn, vẫn connectionless ở tầng giao thức).
Chạy server và client ở hai terminal:
npx tsx udp-server.ts # terminal 1
npx tsx udp-client.ts # terminal 2 → server prints the payload
Hoặc chọc server bằng netcat chế độ UDP:
echo -n "hello udp" | nc -u localhost 41234
Echo server: trả lời người gửi
Một pattern thực tế — đọc datagram, gửi phản hồi về rinfo:
import { createSocket } from 'node:dgram';
const PORT = 41234;
const server = createSocket('udp4');
server.on('message', (msg, rinfo) => {
const text = msg.toString('utf8').trim();
const reply = Buffer.from(`echo: ${text}\n`);
// rinfo tells us exactly where to send the reply — no "connection" remembered.
server.send(reply, rinfo.port, rinfo.address, (err) => {
if (err) console.error('reply failed:', err);
});
});
server.bind(PORT, () => {
console.log(`echo server on udp/${PORT}`);
});
Client tương ứng chờ echo:
import { createSocket } from 'node:dgram';
const PORT = 41234;
const client = createSocket('udp4');
client.on('message', (msg) => {
console.log('received:', msg.toString('utf8'));
client.close();
});
client.send(Buffer.from('hello'), PORT, '127.0.0.1');
Chú ý: client cũng dùng 'message' — socket UDP đối xứng; hai bên đều gửi và nhận được.
Kích thước datagram: trần ~65 507 byte
Payload UDP tối đa lý thuyết ≈ 65 507 byte. Thực tế, giữ datagram nhỏ hơn nhiều — lý tưởng dưới 1 200 byte để tránh phân mảnh IP.
Khi datagram vượt MTU đường truyền (thường 1 500 byte trên Ethernet), kernel chia thành mảnh. Mất một mảnh thì cả datagram bị hủy — UDP không retransmit. Gói nhỏ, không phân mảnh sống sót tốt hơn.
import { createSocket } from 'node:dgram';
const socket = createSocket('udp4');
// ❌ risky on real networks — may fragment and silently fail
const huge = Buffer.alloc(60_000, 0x41);
socket.send(huge, 41234, '127.0.0.1', (err) => {
if (err) console.error(err);
else console.log('sent 60 KB — works on loopback, risky on WAN');
socket.close();
});
Quy tắc ngón tay cái: thiết kế message dưới 512–1 200 byte; nếu cần payload lớn hơn, dùng TCP hoặc chia chunk bằng giao thức app.
Không backpressure, không flow control
TCP làm chậm sender khi buffer receiver đầy — backpressure qua sự kiện 'drain'. UDP không có gì tương tự.
Nếu bắn datagram nhanh hơn receiver xử lý, gói bị drop ở buffer socket OS mà sender không được báo. Bạn phải tự giới hạn tốc độ hoặc xây ACK/retry ở tầng app.
import { createSocket } from 'node:dgram';
const socket = createSocket('udp4');
let sent = 0;
function blast() {
for (let i = 0; i < 100_000; i++) {
// No 'drain' event — send() queues until the kernel buffer overflows, then drops.
socket.send(Buffer.from(`pkt-${i}`), 41234, '127.0.0.1');
sent++;
}
console.log(`fired ${sent} datagrams — many may never arrive`);
socket.close();
}
blast();
Đây là ưu điểm cho workload độ trễ thấp và cạm bẫy cho truyền bulk.
Broadcast & multicast (nhắc qua)
UDP có thể nhắm nhiều host:
- gửi tới
255.255.255.255hoặc địa chỉ broadcast subnet; bậtsocket.setBroadcast(true)trước. - một lần gửi, nhiều subscriber trên địa chỉ nhóm; cần
addMembership()và hỗ trợ OS.
import { createSocket } from 'node:dgram';
const socket = createSocket('udp4');
socket.on('listening', () => {
socket.setBroadcast(true);
const msg = Buffer.from('discover-me');
socket.send(msg, 41234, '255.255.255.255');
console.log('broadcast probe sent');
});
socket.bind(41235);
Giao thức discovery (mDNS, DHCP, lobby game LAN) dựa vào các pattern này — ta sẽ đụng lại ở các phần sau.
TCP vs UDP: so sánh
| TCP | UDP | |
|---|---|---|
| Connection | Yes — 3-way handshake | No — fire-and-forget |
| Reliability | Retransmits lost data | Best-effort; may lose packets |
| Ordering | In-order byte stream | No order guarantee |
| Message boundaries | Not preserved (byte stream) | Preserved (datagram) |
| Flow control | Yes (window, backpressure) | No |
| Congestion control | Yes (built into TCP) | No |
| Header overhead | 20+ bytes + options | 8 bytes |
| Latency | Higher (handshake + retransmit) | Lower (no setup) |
| Typical uses | HTTP, APIs, file transfer | DNS, games, VoIP, metrics |
| Node module | node:net | node:dgram |
Chọn TCP khi đúng không thể thỏa hiệp; chọn UDP khi mới và nhanh quan trọng hơn giao hàng hoàn hảo.
Lỗi người mới hay mắc
- Kỳ vọng tin cậy hoặc thứ tự từ UDP — packet 3 đến trước packet 2 là bình thường; tự thêm số thứ tự và retry nếu cần.
- Gửi datagram khổng lồ rồi thắc mắc vì sao biến mất trên Wi-Fi — phân mảnh giết chúng âm thầm.
- Quên không có kết nối — phải truyền
port+hostmỗi lầnsend(), và dùngrinfođể trả lời đúng peer. - Tưởng localhost giống mạng thật — loopback hiếm khi mất gói; mất và sai thứ tự UDP chỉ lộ khi có tải hoặc đường truyền kém.
Bài tập
Thử từng bài trước khi mở lời giải.
- Xây echo server và client; gửi ba message liên tiếp và xác nhận mỗi reply là sự kiện
messageriêng với prefix đúng. - Gửi datagram 20 000 byte tới echo server trên
localhostrồi giải thích vì sao không nên làm vậy trên LAN thật. - Sửa echo server để drop một nửa datagram (giả lập mất); viết client gửi message đánh số — quan sát khoảng trống trong chuỗi echo.
Lời giải
// echo-server-lossy.ts — drops even-numbered sequence ids
import { createSocket } from 'node:dgram';
const PORT = 41234;
let count = 0;
const server = createSocket('udp4');
server.on('message', (msg, rinfo) => {
count++;
if (count % 2 === 0) return; // simulate 50% packet loss
const reply = Buffer.from(`echo: ${msg.toString('utf8')}`);
server.send(reply, rinfo.port, rinfo.address);
});
server.bind(PORT, () => console.log('lossy echo on', PORT));// seq-client.ts — sends 10 numbered datagrams, logs what comes back
import { createSocket } from 'node:dgram';
const client = createSocket('udp4');
const received: number[] = [];
client.on('message', (msg) => {
const match = msg.toString('utf8').match(/echo: msg-(\d+)/);
if (match) received.push(Number(match[1]));
});
for (let i = 0; i < 10; i++) {
client.send(Buffer.from(`msg-${i}`), 41234, '127.0.0.1');
}
setTimeout(() => {
const expected = Array.from({ length: 10 }, (_, i) => i);
const missing = expected.filter((n) => !received.includes(n));
console.log('received:', received.sort((a, b) => a - b));
console.log('missing (simulated loss):', missing);
client.close();
}, 500);Bài 1: ba lần send() tạo ba sự kiện 'message' độc lập — ranh giới được giữ, khác TCP.
Bài 2: datagram 20 000 byte chạy trên loopback nhưng vượt MTU thường; kernel phân mảnh và mất một mảnh là mất cả datagram trên mạng thật.
Bài 3: với server lossy, khoảng nửa reply đánh số không đến — chứng minh thứ tự và đầy đủ là trách nhiệm của bạn trên UDP.
Điều cốt lõi
UDP không kết nối, theo message, và thẳng thắn — giữ ranh giới datagram, thêm gần như không độ trễ, nhưng không sửa mất gói, sai thứ tự, hay tắc nghẽn giúp bạn. Trong Node, node:dgram ánh xạ gọn: bind để nghe, send(buffer, port, host) để bắn, 'message' để nhận một datagram nguyên với rinfo để trả lời. Giữ payload nhỏ, đừng coi localhost như production, và chọn TCP khi mọi byte phải đến. Tiếp theo: internet biến tên thành địa chỉ thế nào — DNS ở Phần 4.