Trong một hệ microservice, dịch vụ A gọi dịch vụ B. Cách ngây thơ: viết cứng 10.0.0.5:8080 vào cấu hình của A. Nó chạy — cho đến khi B scale từ một bản sao lên năm, hoặc một bản sao chết, hoặc trình điều phối (Kubernetes) cấp lại IP mới cho B sau khi restart. Lúc đó A vẫn gõ vào cái địa chỉ cũ, không biết rằng thế giới đã đổi. Địa chỉ cứng không co giãn được.

Service discovery là câu trả lời: một "sổ đăng ký" động nơi mỗi dịch vụ tự đăng ký khi khởi động và định kỳ chứng minh nó còn sống, còn client tra sổ để tìm một bản sao đang sống mà gọi. Đây là công việc của Consul, etcd, Eureka, và tầng discovery tích hợp trong Kubernetes. Bài này dựng một registry thu nhỏ thật trong Go để thấy rõ ba cơ chế cốt lõi — đăng ký/heartbeat, health check bằng TTL, và discovery với cân bằng tải round-robin — rồi đo thật hành vi tự lành khi một bản sao chết.

Registry: sổ đăng ký trong bộ nhớ

Cấu trúc dữ liệu trung tâm là một map hai tầng: tên dịch vụ → id instance → thông tin instance, mỗi instance nhớ thời điểm heartbeat gần nhất:

type Instance struct {
	ID, Addr string
	lastBeat time.Time // lần heartbeat gần nhất
}
type Registry struct {
	mu   sync.RWMutex
	svcs map[string]map[string]*Instance // service -> id -> instance
	ttl  time.Duration
}

Dùng sync.RWMutex (đã đo ở các bài đồng thời) vì discovery đọc nhiều hơn ghi rất nhiều — nhiều client tra sổ liên tục, còn đăng ký/heartbeat thì thưa hơn.

Đăng ký và heartbeat

Dịch vụ tự khai báo khi khởi động, rồi định kỳ gửi heartbeat để chứng minh nó còn sống:

// dịch vụ tự đăng ký khi khởi động
reg.Register("thanh-toan", "tt-1", "10.0.0.1:8080")

// rồi định kỳ gửi heartbeat
func (r *Registry) Heartbeat(svc, id string) {
	r.mu.Lock(); defer r.mu.Unlock()
	if inst := r.svcs[svc][id]; inst != nil {
		inst.lastBeat = time.Now()
	}
}

Heartbeat là hợp đồng sống-chết: chừng nào một instance còn gửi heartbeat đều đặn, nó được coi là khỏe. Ngừng gửi (vì crash, treo, hoặc mất mạng) thì đến lúc nào đó nó bị coi là chết.

Ảnh chụp đoạn mã Go nền tối minh hoạ service discovery trong Go đăng ký health check TTL và round-robin, vấn đề địa chỉ cứng không co giãn được microservice A gọi B qua IP port cứng B scale lên 5 bản sao 1 bản chết 1 bản đổi IP A không biết cần một sổ đăng ký động dịch vụ tự đăng ký client tra để tìm bản còn sống đây là bản thu nhỏ của Consul etcd Eureka, Registry sổ đăng ký trong bộ nhớ type Instance struct ID Addr string lastBeat time Time lần heartbeat gần nhất type Registry struct mu sync RWMutex svcs map string map string trỏ Instance service tới id tới instance ttl time Duration, đăng ký và heartbeat dịch vụ tự đăng ký khi khởi động reg Register thanh-toan tt-1 10.0.0.1 8080 rồi định kỳ gửi heartbeat để chứng minh còn sống func Heartbeat svc id string r Lock defer Unlock if inst bằng svcs svc id inst khác nil inst lastBeat bằng time Now, health check cộng discover round-robin reap loại instance quá hạn TTL hết heartbeat coi như chết if time Since inst lastBeat lớn hơn ttl delete insts id, Discover trả 1 instance theo round-robin cân bằng tải client sắp ổn định theo ID vì map lặp ngẫu nhiên round-robin mới phân đều sort Slice list func i j int bool return list i ID nhỏ hơn list j ID n bằng atomic AddUint64 rrIndex svc 1 return list int n phần trăm len list xoay vòng qua các bản sống

Hình 1: Service discovery trong Go — registry map hai tầng, dịch vụ tự đăng ký và heartbeat, health check TTL loại bản hết heartbeat, và discover round-robin (sắp ổn định theo ID để phân đều) cân bằng tải phía client.

Health check bằng TTL và discover round-robin

Health check là một vòng quét định kỳ loại bỏ (reap) mọi instance đã quá hạn heartbeat:

// reap: loại instance quá hạn TTL (hết heartbeat = coi như chết)
if time.Since(inst.lastBeat) > r.ttl { delete(insts, id) }

Discover chọn một instance sống theo round-robin để cân bằng tải:

// sắp ỔN ĐỊNH theo ID vì map lặp ngẫu nhiên -> RR mới phân đều
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
n := atomic.AddUint64(r.rrIndex[svc], 1)
return list[int(n)%len(list)] // xoay vòng qua các bản sống

Một chi tiết tinh tế mà tôi vấp phải khi đo: map trong Go lặp theo thứ tự ngẫu nhiên. Nếu ta rút danh sách instance ra từ map rồi lấy chỉ số round-robin trên đó, thứ tự đổi mỗi lần gọi nên round-robin không phân đều (lần đo đầu tôi được 5/2/2 thay vì 3/3/3). Cách sửa đúng: sắp danh sách ổn định (theo ID) trước khi lấy chỉ số. Round-robin chỉ hoạt động khi thứ tự các phần tử ổn định giữa các lần gọi.

Đo thật: round-robin, health check, và tự lành

Đăng ký 3 instance của dịch vụ thanh-toan, rồi discover 9 lần:

Số instance khỏe: 3
Discover 9 lần (round-robin cân bằng tải):
  tt-1 nhận 3 request
  tt-2 nhận 3 request
  tt-3 nhận 3 request

9 request chia đều cho 3 instance — mỗi bản đúng 3. Đây là cân bằng tải phía client: không cần một load balancer riêng, client tự phân phối qua các bản sống.

Giờ mô phỏng tt-2 crash: nó ngừng gửi heartbeat, trong khi tt-1 và tt-3 vẫn gửi đều. Sau khi quá TTL, health check reap:

Health check loại 1 instance chết (tt-2)
Số instance khỏe còn lại: 2

tt-2 bị loại khỏi sổ vì quá hạn heartbeat. Điều quan trọng nhất — discover lại 6 lần:

Discover 6 lần sau khi tt-2 bị loại:
  tt-1 nhận 3 request
  tt-2 nhận 0 request
  tt-3 nhận 3 request

tt-2 (đã chết) nhận 0 request — traffic tự động định tuyến khỏi nó, chia đều cho hai bản còn sống. Không ai phải sửa cấu hình bằng tay; hệ tự lành. Đây chính là giá trị của service discovery: cụm co giãn và chịu lỗi mà client không cần biết chi tiết.

Ảnh chụp bảng kết quả đo thật nền tối service discovery trong Go chạy bằng go run Go 1.23 arm64 registry trong bộ nhớ cộng heartbeat TTL, đăng ký 3 instance dịch vụ thanh-toan số instance khỏe 3 tt-1 tt-2 tt-3 tự đăng ký, discover 9 lần round-robin cân bằng tải tt-1 nhận 3 request tt-2 nhận 3 request tt-3 nhận 3 request 9 request chia đều cho 3 bản mỗi bản đúng 3 nhờ sắp ổn định theo ID map thô cho phân bố lệch, tt-2 crash health check TTL loại nó health check loại 1 instance chết tt-2 số instance khỏe còn lại 2 tt-2 ngừng heartbeat quá TTL reap xóa khỏi sổ tt-1 tt-3 vẫn gửi heartbeat nên vẫn còn, discover 6 lần sau khi loại traffic tự né bản chết tt-1 nhận 3 request tt-2 nhận 0 request bản chết không nhận request nào tt-3 nhận 3 request discovery tự động định tuyến khỏi bản đã chết hệ tự lành, cốt lõi đăng ký dịch vụ tự khai id addr vào sổ khi khởi động heartbeat gửi định kỳ để chứng minh còn sống hết coi như chết TTL reap loại instance quá hạn heartbeat hệ tự dọn bản chết discover client tra sổ chọn 1 bản sống round-robin cân tải tự lành traffic tự né bản chết mà không cần cấu hình tay đánh đổi registry là điểm phụ thuộc TTL ngắn nhạy nhưng ồn heartbeat

Hình 2: Đo thật — round-robin chia 9 request đều 3/3/3 cho ba instance; sau khi tt-2 crash và bị health check TTL loại, discover 6 lần cho tt-2 đúng 0 request còn hai bản sống chia đều 3/3, hệ tự lành.

Đánh đổi cần cân nhắc

Registry là một điểm phụ thuộc mới. Toàn bộ hệ giờ dựa vào sổ đăng ký để tìm nhau. Nếu registry chết, không ai discover được. Đây là lý do các registry sản xuất (Consul, etcd) tự chạy thành cụm đồng thuận (Raft — bài trước!) để chịu lỗi. Registry trong bộ nhớ ở bài này chỉ để minh họa cơ chế; production cần một registry phân tán bền.

TTL là đánh đổi giữa độ nhạy và độ ồn. TTL ngắn phát hiện instance chết nhanh (traffic né sớm), nhưng buộc heartbeat dày hơn — tốn mạng và CPU, và dễ báo nhầm "chết" khi chỉ là một khựng mạng tạm thời. TTL dài thì ngược lại: ít ồn nhưng traffic vẫn gửi tới bản chết lâu hơn trước khi né. Chọn TTL theo mức chấp nhận được của việc "gửi nhầm tới bản chết".

Round-robin không biết tải thực. Round-robin chia đều số lượng request, nhưng không biết instance nào đang bận hay request nào nặng. Một instance chậm vẫn nhận đủ phần của nó. Các chiến lược tinh vi hơn (least-connections, lấy theo độ trễ đo được, EWMA) cân bằng tốt hơn khi request không đồng đều — nhưng phức tạp hơn và cần thu thập thêm số liệu. Round-robin là điểm khởi đầu đơn giản và đủ tốt cho tải đồng đều.

Ba ý mang về

  1. Service discovery thay địa chỉ cứng bằng sổ đăng ký động: dịch vụ tự đăng ký và heartbeat, client tra sổ để tìm bản sống — đo thật, round-robin chia 9 request đều 3/3/3 cho 3 instance mà không cần load balancer riêng.
  2. Health check bằng TTL làm hệ tự lành: instance ngừng heartbeat bị reap khỏi sổ, và discovery tự định tuyến traffic khỏi nó — đo thật, sau khi tt-2 chết thì nó nhận 0 request còn hai bản sống chia đều 3/3.
  3. Nêu rõ đánh đổi: registry là điểm phụ thuộc mới (production phải chạy cụm đồng thuận để bền), TTL cân giữa độ nhạy và độ ồn heartbeat, round-robin đơn giản nhưng không biết tải thực — và nhớ sắp danh sách ổn định để round-robin thật sự phân đều.

Phần sau ta quay về tối ưu tầng dữ liệu trong Go: tinh chỉnh connection pool của database/sql — MaxOpenConns, MaxIdleConns, ConnMaxLifetime ảnh hưởng thế nào tới thông lượng và độ trễ, đo bằng số thật.