Skip to main content

Command Palette

Search for a command to run...

Counter Service

Updated
6 min readView as Markdown
N
Mỗi ngày khỏe mạnh được làm công việc mình yêu thích là một ngày vui

Introduction

Khi build một sản phẩm, ngoài việc xây dựng các tính năng serve cho user, technical team còn phải build các module dùng cho thống kê, phân tích, xây dựng các dashboard để đánh giá sức khỏe của hệ thống cũng như sản phẩm. Counter service là một phần nhìn tưởng đơn giản nhưng đóng vai trò thiết yếu trong việc reporting. Không dừng lại ở đó, counter service còn được dùng cho các hệ thống alert, giúp cảnh báo các bất thường trong system. Trong bài blog này, tôi sẽ giới thiệu design của counter service — một service tưởng đơn giản nhưng cũng khá hay.

Data Model

{
  _id: <long>,          // log entry id, sinh từ timestamp + entry name
  entry: <string>,      // log entry name
  timestamp: <epoch>,   // epoch time, đã được làm tròn theo bucket
  topic: <string>       // log topic
}

Timestamp được làm tròn theo 3 bucket chính: hourly, daily, total. Ví dụ, log view_count thuộc topic impression được scribe vào cả 3 bucket lúc 16h07 ngày 21-11-2023 sẽ tương ứng với 3 entry:

Bucket entry topic timestamp
hourly view_count impression 16h00 21-11-2023
daily view_count impression 0h00 21-11-2023
total view_count impression mốc mặc định (TOTAL_ENTRY_TIME)

Cơ chế gen id

Log entry id là một số long (64 bit) được sinh dựa trên timestamp và entry name, chia thành các vùng bit như sau:

 bit  63 62 | 61                          36 | 35     32 | 31                                0
      ┌──┬──┼───────────────────────────────┼───────────┼──────────────────────────────────┐
      │ 0│ 0│         epoch (26 bit)         │ hash (4b) │        entry hash (32 bit)        │
      └──┴──┴───────────────────────────────┴───────────┴──────────────────────────────────┘
       không dùng         giờ tính từ            hash phụ        hashCode(entry|topic|
       (giữ id dương)     custom epoch          (entry + epoch)   referralId|refType)
Vùng Số bit Vị trí (MSB → LSB) Cách sinh Vai trò
không dùng 2 63–62 luôn = 0 giữ id dương, tránh lỗi khi parse
epoch 26 61–36 số giờ kể từ custom epoch, ghi trực tiếp bằng bit-shift đảm bảo unique tuyệt đối theo giờ — không đi qua hash nên 2 giờ khác nhau chắc chắn cho ra 2 giá trị khác nhau
hash phụ 4 35–32 hash từ entry + epoch, mỗi bit từ một lần hash riêng giảm thêm collision giữa các entry khác nhau trong cùng một giờ
entry hash 32 31–0 hashCode() của composite key (entry, topic, referralId, refType...) phân biệt các entry/composite-key khác nhau

Phần epoch là phần quyết định: vì được ghi trực tiếp bằng bit-shift thay vì đi qua hàm hash, hai timestamp khác giờ nhau chắc chắn cho ra hai giá trị epoch khác nhau — không có xác suất đụng độ. Phần entry hash và hash phụ chỉ đóng vai trò phân biệt các entry khác nhau trong cùng một giờ, và vì bản thân là hash nên vẫn còn xác suất trùng, dù rất nhỏ với 36 bit dành cho việc này.

Với 26 bit cho epoch (2²⁶ giờ), id có thể vận hành chính xác trong khoảng ~7.661 năm kể từ custom epoch

Note: trong quá trình triển khai counter service, việc điều chỉnh cơ chế gen id là không tránh khỏi — số bit dành cho mỗi phần, thuật toán hash, hay cách gộp thêm field mới vào entry đều có thể thay đổi theo thời gian. Để việc này diễn ra mượt mà, cơ chế $setOnInsert khi upsert của MongoDB đã giúp ích rất nhiều: thay vì dùng _id để xác định "đây có phải bản ghi cũ hay không", việc match được chuyển hẳn sang natural key (entry, timestamp, topic...). _id lúc này chỉ còn đóng vai trò một giá trị được gán một lần duy nhất khi insert. Nhờ vậy, dù thuật toán sinh id có đổi giữa chừng, các bản ghi cũ vẫn được tìm thấy và cộng dồn đúng, không phát sinh document trùng lặp hay lỗi duplicate key.

Bulk scribe & batch read

Counter service được serving qua gRPC, việc đọc và ghi đều batching để giảm số lượng database connection. Dựa trên bucket mà request bulk scribe truyền vào, counter service sẽ tạo entry name cũng như quy đổi timestamp theo bucket tương ứng và ghi vào db.

Compound Index để batch read

{
  entry: "hashed",
  timestamp: 1
}

Entry là string dài, việc index bằng hashed giúp tiết kiệm index memory, nhưng trade-off là không thể dùng unique index — nguy cơ race condition khi scribe xuất hiện (2 request cùng ghi một entry mới gần như đồng thời, cả hai đều "không tìm thấy" bản ghi cũ và cùng cố insert). Đến lúc này, việc custom cơ chế gen id kết hợp $setOnInsert phát huy tác dụng: race condition vẫn có thể xảy ra, nhưng chỉ còn là một document trùng lặp thật sự (đúng natural key) chứ không còn là lỗi do lệch cơ chế sinh id — dễ retry và xử lý hơn nhiều so với việc dựa vào một unique index không khả thi.

Tối ưu việc đọc data

Để tính toán tổng counter trong một khoảng thời gian, cách làm ban đầu là query lấy tất cả các entry trong bucket hourly. Ví dụ, với khoảng thời gian từ 19h ngày 26-06-2026 đến 16h ngày 26-08-2026, cách làm này phải quét qua 1.461 entry hourly.

Để tối ưu, việc đọc data được chia làm 3 đoạn, ưu tiên bucket có granularity lớn nhất có thể cho từng phần:

Đoạn Khoảng thời gian Bucket sử dụng Số entry
1 19h 26-06 → 0h 27-06 hourly 5
2 0h 27-06 → 0h 26-08 daily 60
3 0h 26-08 → 16h 26-08 hourly 16
Tổng 81

Từ 1.461 entry xuống còn 81 entry — giảm khoảng 18 lần số lượng bản ghi cần đọc, chỉ bằng cách chọn đúng bucket cho từng đoạn thời gian thay vì luôn quét ở granularity nhỏ nhất.

Những điều rút ra

Trong khi làm sản phẩm, có những service tưởng chừng rất đơn giản nhưng lại yêu cầu tính tin cậy cao. Để service hoạt động chính xác, ổn định, đáp ứng số lượng request lớn, sẽ cần những giải pháp hợp lý và gọn nhẹ.

10 views