Giới thiệu
Khi bạn đang quản lý hàng tá sở thích người dùng và mức độ quyền truy cập, việc sử dụng các cột JSONB hay các bảng với nhiều trường boolean có thể trở nên rắc rối. Tuy nhiên, có một phương pháp nhẹ nhàng hơn: bitmasks. Với một cột BIGINT, bạn có thể mã hóa lên đến 64 cờ, kiểm tra chúng trong thời gian hằng số và giữ cho sơ đồ cơ sở dữ liệu của bạn gọn gàng. Kết hợp điều này với các cột được tạo ra trong Postgres, bạn sẽ có được sự kết hợp hoàn hảo giữa lưu trữ gọn nhẹ và các truy vấn dễ đọc.
Lưu ý: Phương pháp này đi kèm với một số điều kiện. Mặc dù bitmasks nhanh và nhỏ, nhưng chúng cũng có thể tạo ra những phức tạp không cần thiết. Thông thường, tôi chỉ thực hiện phương pháp này trên một loại dự án rất cụ thể và nơi mà các cờ là bất biến hoặc cố định - việc di chuyển có thể gây rắc rối và trên các dự án lớn, lâu dài, đây có thể không phải là lựa chọn tốt nhất.
Trong thực tế, tôi sử dụng phương pháp này khi tôi có rất nhiều cờ tính năng mà có thể dự đoán và phân loại được.
Bitmask là gì?
Bitmasking là một cách đóng gói nhiều giá trị đúng/sai vào một số nguyên duy nhất bằng cách coi mỗi bit như một cờ. Ví dụ:
- Số thập phân
1trong nhị phân là0001- có nghĩa là cờ 0 được đặt. - Số thập phân
2trong nhị phân là0010- có nghĩa là cờ 1 được đặt. - Số thập phân
4trong nhị phân là0100- có nghĩa là cờ 2 được đặt. - Số thập phân
8trong nhị phân là1000- có nghĩa là cờ 3 được đặt.
Bạn có thể kết hợp chúng:
1 | 2=3(nhị phân0011) - các cờ 0 và 1 được đặt.- Để kiểm tra một cờ có tồn tại:
(value & mask) != 0. - Để xóa một cờ:
value & ~mask.
Trong Postgres, BIGINT cho bạn 64 bit để làm việc. Trong Go, uint64 tương ứng trực tiếp với cùng một khái niệm. Trong Javascript... bạn có khoảng 10 bit ít hơn chỉ vì cách mà các số hoạt động. Chúng ta sẽ nói thêm về điều đó sau.
Tại sao nên sử dụng bitmasks?
- Gọn nhẹ: một cột số nguyên có thể theo dõi 64 quyền/ cờ/ thiết lập.
- Nhanh chóng: các kiểm tra bitwise rất tiết kiệm CPU.
- Ghi đơn giản: bật tắt cờ với
|,&, và^.
Ví dụ về sơ đồ:
sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL,
flags BIGINT NOT NULL DEFAULT 0, -- quyền truy cập
prefs BIGINT NOT NULL DEFAULT 0 -- sở thích người dùng
);
Cột được tạo ra để dễ đọc
Bitmasks hiệu quả nhưng có thể rất khó hiểu. Bạn có hai lựa chọn để hiển thị các boolean có tên mà có thể lập chỉ mục và dễ dàng truy vấn.
Lựa chọn A - VIRTUAL (tính toán khi đọc, không lưu trữ)
Kể từ khi Postgres 18 ra mắt, bạn có thể sử dụng các cột được tạo ra ảo (đọc tại thời điểm chạy, không lưu trữ). Tuy nhiên, chúng không thể được lập chỉ mục, vì vậy bạn phải sử dụng một biểu thức để lập chỉ mục chúng.
sql
ALTER TABLE users
ADD COLUMN can_read boolean GENERATED ALWAYS AS
((flags & (1::bigint << 0)) <> 0) VIRTUAL,
ADD COLUMN can_write boolean GENERATED ALWAYS AS
((flags & (1::bigint << 1)) <> 0) VIRTUAL,
ADD COLUMN is_admin boolean GENERATED ALWAYS
AS ((flags & (1::bigint << 2)) <> 0) VIRTUAL;
Lập chỉ mục: Đối với các cột VIRTUAL, tạo chỉ mục biểu thức/ một phần trên biểu thức cơ sở:
sql
CREATE INDEX users_is_admin_expr_idx
ON users (((flags & (1::bigint << 2)) <> 0));
CREATE INDEX users_can_read_partial_idx
ON users (id) WHERE (flags & (1::bigint << 0)) <> 0;
Truy vấn chính xác với những điều kiện đó để trình lập kế hoạch sử dụng các chỉ mục:
sql
SELECT * FROM users WHERE (flags & (1::bigint << 2)) <> 0;
Lựa chọn B - STORED (tính toán khi ghi, có thể lập chỉ mục theo tên)
sql
ALTER TABLE users
ADD COLUMN can_read boolean GENERATED ALWAYS AS
((flags & (1::bigint << 0)) <> 0) STORED,
ADD COLUMN can_write boolean GENERATED ALWAYS AS
((flags & (1::bigint << 1)) <> 0) STORED,
ADD COLUMN is_admin boolean GENERATED ALWAYS AS
((flags & (1::bigint << 2)) <> 0) STORED;
CREATE INDEX users_can_read_idx ON users (can_read);
CREATE INDEX users_is_admin_idx ON users (is_admin);
Dù bạn chọn lựa chọn nào, giờ đây các truy vấn của bạn sẽ trông như thế này:
sql
SELECT * FROM users WHERE is_admin;
Thay vì phải xử lý (flags & (1<<2)) <> 0 ở khắp mọi nơi, bạn có thể tham chiếu đến chỉ mục. Điều này giúp các truy vấn của bạn trở nên sạch sẽ hơn.
Các thao tác phổ biến
sql
-- cấp nhiều quyền
UPDATE users SET flags = flags | :mask WHERE id = :id;
-- thu hồi nhiều quyền
UPDATE users SET flags = flags & ~:mask WHERE id = :id;
-- có tất cả các bit
SELECT * FROM users WHERE (flags & :mask) = :mask;
-- có bất kỳ bit nào
SELECT * FROM users WHERE (flags & :mask) <> 0;
RLS (Row-Level Security) dễ đọc như tiếng Anh
Các chính sách bảo mật theo hàng trở nên rõ ràng hơn khi bạn có thể tham chiếu đến các boolean ảo/cột được tạo:
sql
CREATE POLICY doc_read ON documents
USING (
owner_id = current_setting('app.user_id')::bigint
OR EXISTS (
SELECT 1 FROM users u
WHERE u.id = current_setting('app.user_id')::bigint
AND (u.can_read OR u.is_admin)
)
);
Tích hợp với Go
Ở phía ứng dụng, bitmasks tương ứng tự nhiên với uint64 trong Go với các hàm hỗ trợ bitwise:
go
package perms
const (
PermRead uint64 = 1 << iota
PermWrite
PermAdmin
)
func HasAll(f, m uint64) bool { return f&m == m }
func HasAny(f, m uint64) bool { return f&m != 0 }
func Grant(f, m uint64) uint64 { return f | m }
func Revoke(f, m uint64) uint64 { return f &^ m } // AND-NOT
Bạn có thể cấp hoặc kiểm tra các cờ trong Go, và các truy vấn Postgres có thể sử dụng cùng một vị trí bit. Tuy nhiên, hãy cẩn thận với BIGINT đã ký - trên thực tế, hãy tránh sử dụng bit thứ 63 để các giá trị phù hợp an toàn trong int64.
Tích hợp với TypeScript / Node.js
Nếu bạn đang viết backend bằng Node & TypeScript - bạn có thể sử dụng số cho tối đa 53 bit một cách an toàn. Hãy nhớ rằng, các số trong JavaScript là các số IEEE 754 double. Để có một mặt nạ 64 bit đầy đủ, bạn sẽ muốn sử dụng bigint.
Dưới đây là một mẫu với bigint:
typescript
export const PermRead = 1n << 0n
export const PermWrite = 1n << 1n
export const PermAdmin = 1n << 2n
export function hasAll(flags: bigint, mask: bigint): boolean {
return (flags & mask) === mask
}
export function hasAny(flags: bigint, mask: bigint): boolean {
return (flags & mask) !== 0n
}
export function grant(flags: bigint, mask: bigint): bigint {
return flags | mask
}
export function revoke(flags: bigint, mask: bigint): bigint {
return flags & ~mask
}
// sử dụng
let flags = 0n
flags = grant(flags, PermRead | PermWrite)
console.log(hasAll(flags, PermRead | PermWrite)) // true
Chiến lược lập chỉ mục
- Đường dẫn VIRTUAL: sử dụng biểu thức hoặc chỉ mục một phần trên các kiểm tra cờ bit mà bạn thực sự truy vấn. Giữ các điều kiện văn bản giống hệt trong các truy vấn của bạn để sử dụng chỉ mục.
- Đường dẫn STORED: tạo các chỉ mục btree thuần túy trên các cột boolean được tạo ra (hoặc trên các cửa sổ
smallintđã nhóm) và truy vấn với tên cột. - Bit nhóm: hiển thị một cửa sổ 8 bit dưới dạng
smallintvới một cột được tạo khác nếu bạn lọc theo khoảng.
Cách chọn (hộp quyết định)
Chọn VIRTUAL khi:
- Bạn muốn không có chi phí lưu trữ và luôn có giá trị mới nhất.
- Bạn thoải mái tạo chỉ mục biểu thức/một phần trên một vài điều kiện nóng.
- Bạn chủ yếu đọc; việc ghi thêm từ việc tính toán lại không phải là vấn đề (sẽ không có vấn đề trong ghi).
Chọn STORED khi:
- Bạn muốn chỉ mục đơn giản theo tên cột và dễ dàng tích hợp ORM.
- Bạn thích các giá trị được tính toán được vật chất hóa cho quá trình ETL nhất quán.
- Bạn mong đợi các cột được tạo ra sẽ được sử dụng trong nhiều truy vấn và bảng điều khiển.
Mẹo: Bạn có thể kết hợp chúng. Tạo 2-3 cờ có lưu lượng truy cập cao STORED (được lập chỉ mục theo tên) và giữ lại các cờ còn lại VIRTUAL với các chỉ mục biểu thức/một phần khi cần.
Các đánh đổi trong thực tế
- Giai đoạn loại bỏ các cờ cũ: Bạn có thể giữ một đăng ký trung tâm của các bit (vị trí, tên, ngày triển khai). Khi một cờ không còn được sử dụng, hãy để bit đó không được sử dụng thay vì gán lại ngay lập tức. Điều này giữ cho dữ liệu lịch sử được ổn định và tránh các lỗi tinh vi khi phát lại các bản ghi cũ.
- Kết hợp với JSONB: Bitmasks nổi bật cho các kiểm tra đường dẫn nóng (quyền truy cập, chuyển đổi tính năng), nhưng bạn không muốn đốt các bit cho các sở thích dài hạn. Hãy chọn phân chia nóng & lạnh của bạn: bitmask cho 20-30 boolean hàng đầu, JSONB cho các sở thích hiếm gặp hoặc thử nghiệm. Các mặc định sống trong mã, vì vậy việc thiếu các khóa JSON sẽ không gây ra lỗi.
- Trần di chuyển: Nếu bạn hết bit (63 cờ thực tế), bạn có thể thêm một cột
BIGINTthứ hai (flags2) hoặc chuyển một loại cờ sang cộtBIT(n). Lập kế hoạch phạm vi bit theo miền (0-15 xác thực, 16-31 thanh toán, v.v.) để dễ dàng chia tách sau này. - Kiểm toán: Vì việc chuyển bit có thể nhạy cảm với bảo mật, hãy xem xét một bảng kiểm toán (ví dụ: bảng
users_flag_events) để ghi lại ai đã cấp/thu hồi các bit nào và khi nào.
Khi nào không nên sử dụng bitmasks
- Nếu bạn cần ACL theo tài nguyên, hãy sử dụng các bảng kết nối.
- Nếu bạn cần quyền truy cập động, do người dùng xác định, hãy sử dụng một bảng đồ thị quyền hạn.
- Nếu bạn dựa vào các truy vấn ad-hoc trên nhiều cờ, JSONB hoặc một sơ đồ chuẩn hóa có thể là lựa chọn tốt hơn.
Bitmasks trong Postgres mang đến cho bạn lưu trữ gọn nhẹ và kiểm tra nhanh chóng, trong khi các cột được tạo ra giúp SQL của bạn trở nên dễ sử dụng và có thể lập chỉ mục. Cùng nhau, chúng tạo ra một phương pháp trung gian thực tiễn giữa các số nguyên thô và các bảng quyền truy cập phức tạp - và với một chút lập kế hoạch, chúng có thể mở rộng một cách sạch sẽ khi các cờ của bạn phát triển.