Bảng người dùng của bạn có các cột can_read, can_write, can_delete, is_admin. Tuần sau sếp bảo thêm quyền xuất file, thế là bạn thêm cột thứ năm, sửa API, sửa giao diện, và hy vọng không quên chỗ nào. Có một cách lưu cả năm quyền đó trong một con số duy nhất, không cần đổi bảng khi thêm quyền mới.
Cách đó gọi là bitmask (mặt nạ bit). Nghe giống thứ chỉ dân viết driver mới dùng, nhưng mình gặp nó thường xuyên khi làm web: lưu quyền, bật tắt tính năng, kiểm tra một địa chỉ IP có nằm trong dải được phép không.

Một con số, nhiều công tắc
Máy tính lưu số bằng các chữ số 0 và 1, mỗi chữ số gọi là một bit. Bạn hãy coi mỗi bit là một công tắc: 1 là bật, 0 là tắt.
Gán mỗi quyền một công tắc: đọc là công tắc số 0, ghi là số 1, xoá là số 2, quản trị là số 3. Con số 5 viết dưới dạng bit là 0101, nghĩa là công tắc đọc và công tắc xoá đang bật. Chỉ một con số, nhưng chứa đủ bốn quyền.
Bạn đã gặp cách này rồi mà có thể không để ý. Lệnh chmod 755 trên Linux chính là bitmask: đọc là 4, ghi là 2, chạy là 1, và 7 = 4 + 2 + 1 nghĩa là được cả ba.
Bốn thao tác đủ dùng hằng ngày
Gọi mask là con số đang giữ các công tắc, và flag là công tắc bạn quan tâm. Bốn việc cần biết:
- Bật một quyền:
mask | flag - Tắt một quyền:
mask & ~flag - Đảo một quyền:
mask ^ flag - Hỏi "có quyền này không":
(mask & flag) === flag
Bạn không cần hiểu từng ký hiệu ngay. Chỉ cần nhớ rằng & hoạt động như một cái khung mặt nạ: đặt lên con số, chỉ nhìn thấy công tắc nào nằm trong khung.
Làm thế nào
- Liệt kê quyền, gán mỗi quyền một số là luỹ thừa của 2: 1, 2, 4, 8, 16...
- Đặt tên cho từng số trong code (
READ,WRITE), đừng viết số trần. - Lưu cả con số vào một cột số nguyên trong database.
- Mọi chỗ kiểm tra quyền đều đi qua hàm
has,grant,revokedùng chung, không viết lại phép bit rải rác. - Khi cần thêm quyền, chỉ thêm ở cuối (số tiếp theo trong dãy) và không bao giờ đổi số của quyền đã có. Nếu đổi, dữ liệu cũ trong database sẽ có nghĩa khác.
Một điểm dễ trượt: kiểm tra "có đủ cả hai quyền" khác với "có ít nhất một trong hai". Nhầm hai kiểu này nghĩa là cấp quyền rộng hơn dự định.
Cho bạn nào viết code
Mình chạy lại toàn bộ trên Node 20, Python 3.12 và MariaDB 11.8.
JavaScript:
export const Perm = Object.freeze({
READ: 1 << 0, // 0001
WRITE: 1 << 1, // 0010
DELETE: 1 << 2, // 0100
ADMIN: 1 << 3, // 1000
});
export const has = (mask, flag) => (mask & flag) === flag;
export const grant = (mask, flag) => mask | flag;
export const revoke = (mask, flag) => mask & ~flag;
export const toggle = (mask, flag) => mask ^ flag;
let user = Perm.READ | Perm.WRITE; // 0011
user = grant(user, Perm.DELETE); // 0111
user = revoke(user, Perm.WRITE); // 0101
user = toggle(user, Perm.ADMIN); // 1101
Các bước ra đúng 0011, 0111, 0101, 1101. Với người dùng ở trạng thái 0101, has(user, READ | WRITE) ra false vì thiếu quyền ghi, còn (user & (READ | WRITE)) !== 0 ra true vì có ít nhất một quyền. Hai kiểu hỏi khác nhau, chọn đúng kiểu bạn muốn.
Bẫy nguy hiểm nhất là dấu ngoặc. Trong JavaScript, === được tính trước &. Viết user & Perm.WRITE === Perm.WRITE bị hiểu thành user & (Perm.WRITE === Perm.WRITE), tức user & true, tức user & 1. Mình thử với người dùng đã bị thu quyền ghi: hàm has trả false đúng, còn biểu thức thiếu ngoặc trả giá trị "đúng" vì công tắc đọc đang bật. Một lỗi phân quyền thật sự. Luôn bọc (mask & flag) trong ngoặc.
Python có sẵn IntFlag làm đúng việc này: Perm.WRITE in user để hỏi, user |= Perm.DELETE để cấp, user &= ~Perm.WRITE để thu, và Perm(5) dựng lại tập quyền từ con số lưu trong database (in ra <Perm.READ|DELETE: 5>).
Lưu trong database chỉ cần một cột perms INT UNSIGNED. Mình thử trên một database tạm với bốn người dùng:
SELECT name FROM users WHERE perms & 2 = 2; -- ai có quyền ghi
UPDATE users SET perms = perms | 8 WHERE name = 'dung'; -- cấp quản trị
UPDATE users SET perms = perms & ~2 WHERE name = 'an'; -- thu quyền ghi
Ở MySQL và MariaDB, & được tính trước = nên câu đầu đúng như ý (khác với JavaScript). Cái giá: điều kiện perms & 2 = 2 là phép tính trên cột nên database không dùng được index thường, và EXPLAIN báo type: ALL, tức đọc cả bảng. Bảng vài nghìn người dùng thì không sao. Với hàng triệu dòng cần lọc nhanh, hãy dùng cột riêng có index. Lý do chi tiết có trong bài Truy vấn chậm? Cách đọc EXPLAIN và đặt index đúng chỗ.
Dùng bit để kiểm tra địa chỉ IP
Địa chỉ IPv4 thực chất là một số 32 bit. Khi bạn thấy 192.168.10.77/24, con số 24 nghĩa là 24 bit đầu chỉ ra "khu mạng", 8 bit sau chỉ ra "căn nhà trong khu đó". Muốn biết một IP có thuộc khu mạng đó không, bạn đặt cái khung mặt nạ gồm 24 bit 1 lên IP và so với khu mạng.

export function ipToInt(ip) {
const p = ip.split(".").map(Number);
if (p.length !== 4 || p.some((x) => !Number.isInteger(x) || x < 0 || x > 255)) throw new Error("IPv4 không hợp lệ");
return ((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3]) >>> 0;
}
export function prefixToMask(prefix) {
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) throw new Error("prefix 0..32");
return prefix === 0 ? 0 : (0xFFFFFFFF << (32 - prefix)) >>> 0;
}
export function inCidr(ip, cidr) {
const [base, p] = cidr.split("/");
const mask = prefixToMask(Number(p));
return ((ipToInt(ip) & mask) >>> 0) === ((ipToInt(base) & mask) >>> 0);
}
Chạy với 192.168.10.77/24 ra mạng 192.168.10.0, mặt nạ 255.255.255.0, 254 máy. Với 10.0.0.5/13 ra mặt nạ 255.248.0.0. Mình còn so hàm này với module ipaddress của Python trên 2.000 ca ngẫu nhiên, không ca nào lệch.
Hai chỗ trong đoạn này không được bỏ. Một là >>> 0, vì JavaScript trả số có dấu 32 bit, nên không có nó thì 200.1.1.1 AND với 255.0.0.0 ra số âm -939524096 và phép so sánh sai. Hai là nhánh prefix === 0, vì JavaScript lấy số bit dịch theo modulo 32, nên x << 32 thành x << 0 và /0 ra mặt nạ toàn 1 thay vì toàn 0. Với IPv6 hoặc việc quan trọng, hãy dùng thư viện có sẵn (ipaddress của Python, net.BlockList của Node).
Vì sao JavaScript hay làm bạn bất ngờ
JavaScript ép mọi phép bit về số nguyên 32 bit có dấu. Mình thử các trường hợp hay gặp:
1 << 31ra-2147483648, và1 << 32ra1, không phải 4294967296.2 ** 32 | 0ra0. Số vượt 32 bit bị cắt âm thầm, nên dấu thời gian tính bằng mili giây đưa qua| 0sẽ hỏng.~5ra-6, không phải10.
Cần quá 32 bit thì dùng BigInt: 1n << 40n ra 1099511627776n. Lưu ý không trộn được BigInt với số thường, 1n | 1 ném TypeError. Python không dính các chuyện này vì số nguyên không giới hạn, nhưng ~5 cũng ra -6, và bạn phải tự & 0xFF nếu muốn mô phỏng độ rộng cố định.
Lỗi hay gặp
Lỗi đau nhất là chèn quyền mới vào giữa dãy rồi đánh số lại cho gọn. Dữ liệu đã lưu giữ nguyên con số cũ, chỉ có ý nghĩa của số bị đổi, nên mọi người dùng cũ đổi quyền một cách âm thầm. Vì vậy mới có luật chỉ thêm ở cuối.
Một lỗi quen thuộc khác là dùng số không phải luỹ thừa của 2, ví dụ 3, cho một quyền. Số 3 là hai công tắc cùng bật, nên quyền đó tự dính vào quyền khác.
Còn chuyện hay gặp trong bài toán IP: ai đó dùng | 0 hay >> 0 "cho gọn" rồi tự hỏi vì sao IP lớn bị so sai. Câu trả lời nằm ở phần JavaScript ở trên.
Nên làm gì tiếp theo
Nếu bảng của bạn đang có nhiều cột true/false về quyền hoặc tính năng, thử gom chúng vào một cột bitmask cho dữ liệu nhỏ, và giữ hàm has, grant, revoke ở một chỗ duy nhất. Nếu quyền cần lọc nhanh trên bảng lớn, giữ cột riêng có index. Hai cách không loại trừ nhau, chọn theo chỗ cần.



