
Định luật Hyrum trong Go: Khi một dòng error string trở thành API Contract bất biến
Mục lục
Có những dòng comment trong Go stdlib mà đọc xong mình phải đóng laptop lại vài phút. Không phải vì nó khó hiểu, mà vì nó quá thẳng thắn về một sự thật mà hầu hết chúng ta thích giả vờ không biết.
Mình đang mò vào net/http/request.go để xem Go chặn request body quá lớn như thế nào, thì gặp đúng đoạn này:
func (e *MaxBytesError) Error() string {
// Due to Hyrum's law, this text cannot be changed.
return "http: request body too large"
}
Không phải // TODO, không phải //nolint, không phải một lời xin lỗi. Đây là một câu tuyên bố rõ ràng rằng: chuỗi 28 ký tự kia không còn là chuỗi nữa. Nó là hợp đồng. Một hợp đồng mà chính Go team cũng không còn quyền sửa, dù họ viết ra nó.
Đó là thời điểm mình hiểu Hyrum’s Law không phải một câu nói vui trong thế giới API design. Nó là một định luật mô tả cách phần mềm tích tụ nghĩa vụ theo thời gian, giống như cách một hàm số tích tụ diện tích dưới đường cong. Bạn không cảm nhận được nó trong một sprint. Bạn chỉ thấy nó ở cái release mà mọi thứ đều đúng spec mà vẫn có người sập production.
1. Định luật, phát biểu lại cho người viết backend
Nguyên bản của Hyrum Wright, kỹ sư Google:
With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody.
Tạm dịch: khi API có đủ nhiều người dùng, những gì bạn hứa trong hợp đồng không còn quan trọng nữa. Mọi hành vi quan sát được của hệ thống sẽ trở thành thứ mà một ai đó phụ thuộc vào.
Cần nhấn mạnh chữ observable. Hyrum’s Law không nói về những gì bạn publish trong documentation. Nó nói về những gì người khác có thể đo được bằng mắt, bằng log, bằng tcpdump, bằng diff, bằng một cái test so sánh hai chuỗi byte.
Mình hay mô hình hoá hợp đồng thực tế của một service như sau:
Contract_thực_tế = Spec_hứa_trong_docs ∪ Hành_vi_quan_sát_được
Và vế thứ hai mới là vế nguy hiểm, vì nó là tập vô hạn, không ai liệt kê được, nhưng người dùng thì liệt kê nó dần dần bằng chính code của họ.
Hãy chia hành vi thành ba tầng:
| Tầng | Ví dụ cụ thể trong Go | Ai phụ thuộc | Chi phí thay đổi |
|---|---|---|---|
| Hứa rõ ràng | MaxBytesReader trả *MaxBytesError khi vượt limit | Dev đọc doc, errors.As | Thấp, đây là việc chúng ta được phép làm |
| Quan sát được | Chuỗi "http: request body too large" | Alert rule, log parser, SDK, test snapshot | Cao, cần deprecation |
| Tình cờ | Thứ tự field khi marshal JSON, thứ tự row DB trả về | Client bug, snapshot test, tài liệu nội bộ | Rất cao, có khi là vĩnh viễn |
Điều khiến định luật này nghe như “luật” chứ không phải “lời khuyên” là tính tất yếu của nó. Không phải xác suất một người dùng phụ thuộc vào một chi tiết tình cờ. Mà là: với đủ nhiều người dùng và đủ nhiều thời gian, xác suất đó tiến về 1. Với API có một triệu user, bạn không còn được phép nói “không ai làm thế đâu”, vì trên thực tế đã có người làm thế rồi, và người đó đang chạy trong production với SLA 99.99%.
flowchart LR
A["Một hành vi quan sát được
vô tình xuất hiện"] --> B["Một dev viết test
hoặc dựng alert dựa vào nó"]
B --> C["Phụ thuộc lan ra:
SDK, dashboard, runbook"]
C --> D["Chi phí thay đổi
cao hơn chi phí giữ nguyên"]
D --> E["Hành vi được ghi nhận
trong code là bất biến"]
E --> A
Vòng lặp này không có điểm dừng. Mỗi lần giữ nguyên một hành vi vì lý do tương thích, bạn lại cấp thêm thời gian cho người mới phụ thuộc vào nó. Đó là lý do Hyrum’s Law và backward compatibility là hai cuốn tiểu thuyết cùng một nhân vật chính.
2. Ba vết sẹo trong Go stdlib
Go may mắn, hoặc xui xẻo, là có một nhóm stdlib được hàng triệu chương trình dùng, nên các “vết sẹo” của Hyrum’s Law lộ ra rất rõ. Cả ba chỗ mình sắp kể đều nằm trong chính source mà bạn có thể go env GOROOT rồi mở ra đọc.
2.1 net/http: chuỗi 28 ký tự không được phép sửa
Xét trường hợp cụ thể. http.MaxBytesReader là cách chuẩn để giới hạn body của một request. Khi vượt, nó trả về *http.MaxBytesError:
type MaxBytesError struct {
Limit int64
}
func (e *MaxBytesError) Error() string {
// Due to Hyrum's law, this text cannot be changed.
return "http: request body too large"
}
Nếu bạn viết service nhận upload ảnh, chặn ở 10MB, và client gửi 20MB, error này trả về đúng như trên. Nghe rất bình thường. Nhưng hãy để ý: kiểu MaxBytesError mới được thêm vào Go 1.19. Trước đó người ta chỉ có một errors.New("http: request body too large") bên trong maxBytesReader. Và rất nhiều codebase đã làm thế này trong nhiều năm:
// ĐỪNG LÀM THẾ NÀY.
if strings.Contains(err.Error(), "request body too large") {
http.Error(w, "file quá lớn", http.StatusRequestEntityTooLarge)
return
}
Hoặc tệ hơn, nằm trong một alert rule:
log: status=400 AND message~"request body too large"
Hoặc trong một runbook ghi rằng “nếu thấy log này thì mở dashboard upload”. Đến lúc đó, chuỗi kia không còn là implementation detail. Nó là một cái tên. Và cái tên thì không đổi được nữa.
Đây là chi tiết mình thích nhất: Go team đã làm đúng mọi thứ có thể làm đúng. Họ thêm một kiểu lỗi có cấu trúc, có trường Limit, để người dùng viết errors.As thay vì so chuỗi. Đó là thiết kế tốt. Nhưng họ vẫn phải đóng băng chuỗi cũ, vì chỉ cần đổi dấu hai chấm thành dấu phẩy, sẽ có service trả 500 thay vì 413, và không ai trace ra nguyên nhân trong vòng một buổi chiều.
Rút ra: bạn có thể thêm một API đúng đắn, nhưng bạn không thể rút lại một chuỗi đã lỡ đi ra internet.
Warning
Nếu codebase của bạn có bất kỳ chỗ nào so sánh err.Error() với một literal, đó không phải là error handling. Đó là một dependency vào bản dịch tiếng Anh của một sự kiện, và nó sẽ vỡ đúng vào lần bạn nâng version dependency hoặc thêm middleware wrap lỗi.
2.2 crypto/rsa: khi Go cố tình phá tính deterministic
Chỗ thứ hai tinh tế hơn nhiều. Trong crypto/rsa, hàm EncryptOAEP có comment:
func EncryptOAEP(hash hash.Hash, random io.Reader, pub *PublicKey, msg []byte, label []byte) ([]byte, error) {
// Note that while we don't commit to deterministic execution with respect
// to the random stream, we also don't apply MaybeReadByte, so per Hyrum's
// Law it's probably relied upon by some. It's a tolerable promise because a
// well-specified number of random bytes is included in the ciphertext, in a
// well-specified way.
MaybeReadByte là gì? Nó nằm trong crypto/internal/randutil và trông như một trò đùa của người viết compiler:
var (
closedChanOnce sync.Once
closedChan chan struct{}
)
// MaybeReadByte reads a single byte from r with ~50% probability. This is used
// to ensure that callers do not depend on non-guaranteed behaviour, e.g.
// assuming that rsa.GenerateKey is deterministic w.r.t. a given random stream.
func MaybeReadByte(r io.Reader) {
closedChanOnce.Do(func() {
closedChan = make(chan struct{})
close(closedChan)
})
select {
case <-closedChan:
return
case <-closedChan:
var buf [1]byte
r.Read(buf[:])
}
}
Nhìn kỹ cái select. Hai case cùng đọc từ một channel đã đóng. Cả hai luôn ready. Go runtime chọn ngẫu nhiên giữa các case ready, nên hàm này đọc thêm 1 byte với xác suất đúng 50%. Không cần rand, không cần đồng bộ, chỉ cần hiểu semantics của select. Đây là một trong những đoạn code đẹp nhất trong toàn bộ Go stdlib, và nó tồn tại chỉ để làm hỏng code của người khác.
Hỏng theo nghĩa tích cực: ai đó viết rsa.GenerateKey(rand.Reader, 2048) hai lần và kỳ vọng hai key khác nhau, thì đúng. Nhưng ai đó viết một test giả lập io.Reader và đếm chính xác số byte bị đọc, hoặc dựng lại private key từ một chuỗi byte cố định, sẽ vỡ ngay. Đó chính là mục tiêu. Go chủ động phá tính deterministic trước khi người dùng kịp biến nó thành hợp đồng.
Nhưng để ý vế thứ hai của comment: EncryptOAEP không gọi MaybeReadByte. Tại sao? Vì với OAEP, số byte ngẫu nhiên cần dùng đã được đặc tả trong RFC 8017: seed dài đúng bằng kích thước hash, nằm ở đúng vị trí trong ciphertext. Nếu phá thêm, bạn sẽ làm hỏng những thư viện đang cố implement đúng spec, ví dụ một HSM giả lập hay một bộ test vector. Go team đã cân nhắc: chỗ nào tính deterministic là tình cờ thì phá, chỗ nào nó là hệ quả của spec thì giữ và ghi nhận thành “tolerable promise”.
Đây là bài học thiết kế đáng giá nhất trong bài này: phòng thủ trước Hyrum’s Law không phải là làm mọi thứ random. Nó là phân loại chính xác cái gì đáng được ổn định và cái gì không.
2.3 internal/weak: cấm cửa trước khi có người gõ cửa
Chỗ thứ ba là một comment ở đầu package, không phải trong một hàm:
/*
Using go:linkname to access this package and the functions it references
is explicitly forbidden by the toolchain because the semantics of this
package have not gone through the proposal process. By exposing this
functionality, we risk locking in the existing semantics due to Hyrum's Law.
If you believe you have a good use-case for weak references not already
covered by the standard library, file a proposal issue at
https://github.com/golang/go/issues instead of relying on this package.
*/
internal/weak là hạ tầng cho weak pointer: con trỏ không giữ object sống, và có thể trả nil bất cứ lúc nào sau khi GC xác định object không còn reachable. Phần “bất cứ lúc nào” đó là một mỏ vàng của các câu hỏi chưa có đáp án ổn định: weak pointer trở thành nil trước hay sau khi finalizer chạy? Nếu object sống lại qua finalizer thì weak pointer cũ có equal với weak pointer mới không? Nếu hai weak pointer trỏ vào hai field khác nhau của cùng một struct thì sao?
Go team chưa muốn trả lời những câu này một cách long trọng. Và cách duy nhất để không phải trả lời vĩnh viễn là không cho ai chạm vào. Vì một khi go:linkname mở được cửa, câu trả lời sẽ bị đóng băng bởi người dùng đầu tiên, không phải bởi người thiết kế.
Đến Go 1.24, package weak được public hoá, nhưng chỉ sau khi semantics đã đi qua proposal process và được viết thành documentation. Đó là trình tự đúng: tranh luận trước, đóng băng sau. Không phải đóng băng rồi mới tranh luận, vì lúc đó đã muộn.
3. Go 1.23 và cuộc chiến //go:linkname
Nếu ba ví dụ trên là Hyrum’s Law ở dạng “âm thầm”, thì //go:linkname là dạng “vũ khí”. Đây là chỉ thị của compiler cho phép bạn khai báo một hàm không có body và bảo linker nối nó vào một symbol ở package khác, bỏ qua toàn bộ type system, bỏ qua internal/, bỏ qua mọi thứ mà Go dùng để bảo vệ bạn khỏi chính mình.
package fastpath
import (
_ "unsafe" // bắt buộc, chỉ để thoả điều kiện của compiler
)
//go:linkname nanotime runtime.nanotime
func nanotime() int64
func Now() int64 {
return nanotime()
}
Hai dòng này đủ để bạn gọi thẳng vào một hàm chưa từng được export, chưa từng được document, và có thể đổi tên bất cứ lúc nào. Không có compiler error khi Go đổi signature. Chỉ có undefined symbol ở bước link, hoặc tệ hơn: link thành công nhưng gọi sai ABI và crash với một stack trace không đọc được.
3.1 Push, Pull, Handshake
Russ Cox mô tả ba dạng sử dụng trong issue golang/go#67401:
- Push: package A tự gắn
//go:linknamelên symbol của mình để “đẩy” nó sang tênB.foo, rồi package B khai báofunc foo()không body. A chủ động cho B dùng. - Pull: package A định nghĩa
foobình thường, package B tự//go:linknameđể “kéo” nó về. A có thể chưa từng nghe tên B. Đây là dạng nguy hiểm nhất. - Handshake: cả hai bên cùng khai báo
//go:linknamevà đồng ý về cái tên. Đây là dạng duy nhất có thể gọi là hợp đồng.
flowchart TB
subgraph "Pull: một chiều, ngầm, dễ vỡ"
R1["package runtime
func nanotime()"]
F1["package fastjson
go:linkname nanotime runtime.nanotime"]
F1 -->|"gọi xuyên internal/
không có sự đồng ý"| R1
end
subgraph "Handshake: hai chiều, có ghi nhận"
R2["package runtime
go:linkname nanotime
func nanotime()"]
F2["package fastjson
go:linkname nanotime runtime.nanotime"]
F2 <-->|"cả hai cùng khai báo
linker mới cho qua"| R2
end
3.2 Sự cố goccy/go-json
Tháng 5 năm 2024, một thay đổi nội bộ trong runtime đã làm vỡ github.com/goccy/go-json. Lý do không phải vì thư viện đó dùng một API bí mật nào đó, mà vì nó copy gần như toàn bộ internal type API của runtime vào code của mình. Kết quả là Go team rơi vào thế không thể đổi bất cứ thứ gì trong danh sách đó mà không làm vỡ một thư viện được Kubernetes dùng.
Trích lời Russ Cox trong issue:
Overuse of //go:linkname to reach into Go standard library internals (especially runtime internals) means that when we do change the standard library internals in ways that should not matter, we can end up breaking packages that are depended on by a large swath of the Go ecosystem.
Điều đáng chú ý: đây là Hyrum’s Law tác động lên những symbol không hề được export. internal/ trong Go là một quy ước xã hội được compiler hỗ trợ, không phải một bức tường vật lý. //go:linkname biến quy ước đó thành lời đề nghị lịch sự. Và một khi đã có người bước qua, lời đề nghị trở thành hợp đồng.
3.3 Cách Go 1.23 xử lý, và cái giá của nó
Go 1.23 bật -checklinkname=1 mặc định ở linker. Từ giờ, muốn link vào symbol nội bộ của stdlib, symbol đó phải được đánh dấu //go:linkname ngay tại định nghĩa của nó, tức là phải ở dạng Handshake. Nghe rất dứt khoát. Nhưng đọc kỹ release note:
For backward compatibility, existing usages of //go:linkname found in a large open-source code corpus remain supported. Any new references to standard library internal symbols will be disallowed.
Đây là chỗ mình dừng lại lâu nhất. Để làm được câu đó, Go team đã phải khảo sát toàn bộ các package open-source đang tồn tại, tìm mọi chỗ đang Pull các symbol nội bộ, rồi thêm annotation vào stdlib để giữ chúng tiếp tục chạy. Nghĩa là chính cơ chế chống Hyrum’s Law cũng bị Hyrum’s Law bẻ cong: họ không thể cấm những vi phạm đã tồn tại, chỉ có thể cấm những vi phạm mới.
Đây là một bài học system design rất thật. Khi bạn phát hiện một coupling xấu đã tồn tại, bạn thường có hai lựa chọn: phá nó ngay và chịu đau một lần, hoặc đóng băng nó và chặn nó lan rộng. Go chọn cách thứ hai, cộng thêm một cái van:
go build -ldflags=-checklinkname=0 ./...
Van này cho phép thí nghiệm, cho phép fork nội bộ, cho phép một startup chấp nhận rủi ro. Nhưng nó đủ khó chịu để không ai vô tình đưa nó vào CI của một project nghiêm túc. Thiết kế “mặc định cứng, có escape hatch được document” thường tốt hơn “cấm tuyệt đối”, vì cấm tuyệt đối chỉ đẩy người ta sang fork compiler hoặc pin version cũ.
3.4 Ai trả nợ?
Các thư viện JSON hiệu năng cao như sonic, jsoniter, goccy/go-json đều có lý do rất chính đáng để chạm vào runtime: encoding/json dùng reflection, reflection dùng interface, interface gây allocation, và ở tải 100K request/giây thì phần chênh lệch đó là tiền thật. Họ tối ưu đúng bài. Nhưng họ vay nợ từ tương lai của ngôn ngữ, và người trả nợ là bạn, vào cái ngày bạn nâng Go version và thấy service không boot được vì undefined: runtime.xxx.
Thực tế mình khuyên: nếu dùng những thư viện này, hãy pin Go version trong go.mod và Dockerfile, chạy CI matrix với ít nhất một version Go mới hơn production, và đọc release note phần Linker trước khi nâng. Một buổi đọc 10 phút rẻ hơn nhiều so với một đêm rollback.
4. Map iteration: phòng thủ chủ động
Đây là ví dụ mình dùng nhiều nhất khi giải thích Hyrum’s Law cho dev mới, vì ai cũng đã từng thấy nó mà không biết tại sao.
Trong runtime/map.go, hàm mapiterinit chọn điểm bắt đầu duyệt map như sau:
// decide where to start
r := uintptr(rand())
it.startBucket = r & bucketMask(h.B)
it.offset = uint8(r >> h.B & (bucketCnt - 1))
Để hiểu vì sao cần hai dòng chứ không phải một, phải nhớ layout: một map Go là mảng các bucket, mỗi bucket chứa tối đa 8 cặp key/value (bucketCnt = 8), cộng một chuỗi overflow bucket khi hash va chạm nhiều. h.B là log2 số bucket. bucketMask(h.B) là 2^B - 1, dùng để lấy phần thấp của số random.
Dòng đầu chọn bucket bắt đầu. Dòng thứ hai chọn offset bên trong bucket. Nếu chỉ có dòng đầu, map nhỏ (một bucket, B = 0) sẽ luôn duyệt slot 0, 1, 2… theo đúng thứ tự chèn, và mọi test sẽ pass ổn định cho đến khi map lớn lên và vỡ theo cách không ai hiểu. Dòng thứ hai bịt đúng cái lỗ đó.
flowchart LR
subgraph "Mảng bucket, B = 2"
B0["bucket 0
8 slot"]
B1["bucket 1
8 slot"]
B2["bucket 2
8 slot"]
B3["bucket 3
8 slot"]
end
R["rand()"] --> S["startBucket = r AND 3"]
R --> O["offset = (r >> 2) AND 7"]
S --> B2
O --> B2
B2 --> N["thứ tự duyệt: 2, 3, 0, 1
trong mỗi bucket bắt đầu từ offset"]
Hệ quả là ba thứ mà dev hay vô tình tin:
- Map nhỏ giữ thứ tự chèn. Sai. Offset được random.
- Hai vòng
for rangeliên tiếp trên cùng một map cho cùng thứ tự. Sai. Mỗi vòng córand()riêng. - Nếu không ghi vào map thì thứ tự ổn định trong suốt vòng đời process. Sai, cùng lý do.
Đến Go 1.24, map được viết lại bằng Swiss table, cấu trúc dữ liệu hoàn toàn khác. Nhưng tính random vẫn được giữ nguyên, vì đó là một cam kết: “không có thứ tự” là một phần của spec. Đây là nghịch lý đẹp của Hyrum’s Law: thứ duy nhất bạn được phép hứa ổn định là sự bất ổn định.
Và đây là lý do mình luôn chạy test với -count=20 -shuffle=on trước khi merge một thay đổi liên quan đến map hoặc set. Nếu test chỉ fail 1/20 lần, gần như chắc chắn bạn đang phụ thuộc vào thứ tự duyệt. Đó là món quà miễn phí từ runtime, hãy dùng nó.
5. Bề mặt hợp đồng của một backend Go
Khi đã nhìn Hyrum’s Law như một định luật, việc thiết kế API trở thành bài toán quản lý bề mặt. Bạn không kiểm soát được người dùng sẽ bám vào đâu, nhưng bạn kiểm soát được có bao nhiêu thứ để bám.
flowchart TD
API["Bề mặt của một service Go"]
API --> A1["Tên hàm, kiểu, interface export"]
API --> A2["Error value và error string"]
API --> A3["Bytes JSON hoặc protobuf"]
API --> A4["Thứ tự row từ database"]
API --> A5["Tên log field, metric label"]
API --> A6["Timing, allocation, thứ tự goroutine"]
A1 --> C1["Rẻ: thêm mới, version hoá"]
A2 --> C2["Đắt: alert, runbook, SDK"]
A3 --> C2
A4 --> C3["Rất đắt: bug client, snapshot test"]
A5 --> C3
A6 --> C4["Không kiểm soát: chỉ có thể ngừng hứa"]
Điểm mấu chốt: chi phí thay đổi không tỉ lệ với độ phức tạp kỹ thuật, mà tỉ lệ với số người đã quan sát. Đổi một field name trong JSON là việc 5 phút. Đổi nó sau khi 200 đối tác đã parse là việc của một quý.
6. Playbook thực chiến
Phần này là những gì mình áp dụng trong codebase thật, không phải lý thuyết.
6.1 Error: dùng value, không dùng chữ
Nguyên tắc: error string là để đọc, error value là để lập trình.
var (
ErrUserNotFound = errors.New("user not found")
ErrQuotaExceeded = errors.New("quota exceeded")
)
type ValidationError struct {
Field string
Rule string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Rule)
}
Nơi kiểm tra:
if errors.Is(err, ErrUserNotFound) {
return nil, status.Error(codes.NotFound, "user not found")
}
var ve *ValidationError
if errors.As(err, &ve) {
return nil, status.Errorf(codes.InvalidArgument, "field %s: %s", ve.Field, ve.Rule)
}
errors.Is đi theo chuỗi Unwrap, so sánh bằng == trước, rồi gọi method Is nếu có. errors.As cũng đi theo chuỗi đó nhưng tìm kiểu. Nghĩa là bạn có thể wrap bao nhiêu lớp tuỳ thích mà logic phía trên vẫn đúng, miễn là bạn dùng %w chứ không dùng %v.
// Đúng: giữ nguyên chuỗi unwrap.
return fmt.Errorf("loading profile %d: %w", id, ErrUserNotFound)
// Sai: biến error thành một chuỗi chết.
return fmt.Errorf("loading profile %d: %v", id, ErrUserNotFound)
Khi cần so sánh theo thuộc tính, tự implement Is:
func (e *ValidationError) Is(target error) bool {
t, ok := target.(*ValidationError)
return ok && t.Field == e.Field
}
Còn ở biên HTTP hoặc gRPC, đừng bao giờ để err.Error() là tín hiệu duy nhất. Hãy trả một envelope có code ổn định, message tự do, và retryable:
type APIError struct {
Code string `json:"code"` // hợp đồng, không đổi
Message string `json:"message"` // tự do, có thể đổi
Retryable bool `json:"retryable"` // hợp đồng
}
Client đọc code. Con người đọc message. Đây là cách duy nhất để bạn còn quyền sửa câu chữ mà không phá ai.
6.2 Ordering: đừng để database chọn hộ bạn
Query không có ORDER BY là một Hyrum’s Law time bomb. PostgreSQL có thể trả cùng một thứ tự trong 6 tháng vì plan hiện tại đang dùng index scan, rồi đổi sang bitmap scan sau khi table lớn thêm 20% và thứ tự thay đổi. Không có gì sai. Chỉ là bạn đã hứa một điều bạn không hề hứa.
-- Tệ: thứ tự phụ thuộc plan
SELECT id, name FROM users WHERE status = 'active' LIMIT 20;
-- Tốt: thứ tự tường minh, có tiebreaker duy nhất
SELECT id, name FROM users
WHERE status = 'active'
ORDER BY created_at DESC, id DESC
LIMIT 20;
Cái id DESC không thừa. Nếu 500 user được tạo cùng một giây, created_at là tie, và không có tiebreaker thì thứ tự giữa chúng là tuỳ ý. Với pagination, điều đó có nghĩa là một row có thể xuất hiện hai lần hoặc biến mất giữa hai trang. Đây là bug mà mình đã phải debug trong một hệ thống có 40 triệu row, và nguyên nhân đúng là thiếu tiebreaker.
Với Go, cùng nguyên tắc:
keys := maps.Keys(m) // hoặc slices.Collect(maps.Keys(m)) tuỳ version
slices.Sort(keys)
Và sort.Slice là unstable. Từ Go 1.19 nó dùng pdqsort, nhanh hơn nhưng hoán vị các phần tử bằng nhau theo cách không đảm bảo. Nếu bạn cần giữ thứ tự tương đối của các phần tử tie, dùng sort.SliceStable, hoặc tốt hơn: thêm tiebreaker vào comparator để không phụ thuộc vào stability.
6.3 Serialization: bytes ra internet là hợp đồng
Ba điều mình luôn làm:
Thứ nhất, không marshal struct nội bộ trực tiếp. Luôn có DTO riêng ở biên. Struct nội bộ sẽ đổi, và mỗi lần đổi là một lần breaking.
type userRow struct { // trong database layer
ID int64
Email string
DeletedAt sql.NullTime
}
type userResponse struct { // ở HTTP layer, hợp đồng nằm ở đây
ID int64 `json:"id"`
Email string `json:"email"`
}
Thứ hai, tag tường minh cho mọi field. Không có tag nghĩa là tên field Go trở thành tên field JSON, và refactor nội bộ trở thành breaking change công khai.
Thứ ba, hiểu omitempty là hợp đồng chứ không phải tiện ích. json:"name,omitempty" khiến client không phân biệt được “không có field” và “field bằng zero value”. Với PATCH hoặc partial update, đó là bug logic chứ không chỉ là chuyện format.
Về thứ tự: encoding/json sắp xếp key của map theo alphabet, nên phần đó an toàn. Nhưng thứ tự field trong struct thì theo thứ tự khai báo, và các client dùng snapshot test hoặc strict parser có thể phụ thuộc vào nó. Với hợp đồng dài hạn, protobuf với field number là lựa chọn tốt hơn JSON, vì field number mới là định danh thật, còn tên field chỉ là comment.
6.4 Time và concurrency: chỗ Hyrum’s Law ẩn mình kỹ nhất
Go 1.23 đổi channel của time.Timer và time.Ticker từ buffered (cap 1) sang unbuffered (cap 0). Nghe như chi tiết nội bộ. Nhưng len(t.C) và cap(t.C) là quan sát được, và có code đã dùng len để đoán xem receive có block hay không.
// Code này đúng với Go 1.22, sai với Go 1.23+.
if len(timer.C) > 0 {
v := <-timer.C
_ = v
}
Go team xử lý bằng đúng bộ công cụ đã mô tả ở trên: hành vi mới chỉ bật khi go.mod ghi go 1.23.0 trở lên, cộng thêm GODEBUG=asynctimerchan=1 để quay lại hành vi cũ. Đây là một compatibility ratchet hoàn chỉnh, và là hình mẫu để bạn copy cho API nội bộ của mình.
Cùng nhóm này: đừng phụ thuộc vào thứ tự goroutine hoàn thành, đừng phụ thuộc vào việc select chọn case nào khi nhiều case ready, đừng phụ thuộc vào việc runtime.GOMAXPROCS là bao nhiêu khi viết benchmark. Tất cả đều quan sát được và tất cả đều có thể đổi.
6.5 Contract test
Một hợp đồng không có test là một hợp đồng sẽ bị phá bởi chính team bạn sau 6 tháng. Mình dùng ba lớp:
- Golden test cho payload: serialize một struct cố định, so sánh với file
.golden. Khi ai đó đổi tag hoặc thêm field, test đỏ và buộc phải quyết định có chủ đích. - Round-trip test:
decode(encode(x)) == x, cộng thêm test với dữ liệu lạ (null, số rất lớn, chuỗi Unicode) để bắt các giả định ngầm. - Fuzz test cho parser và cho error path.
go test -fuzz=FuzzDecodethường tìm ra những case mà bạn chưa từng nghĩ tới, đặc biệt là các chuỗi lỗi bị match nhầm.
Và chạy test với -race -count=20 -shuffle=on trong CI ít nhất một job. Cái -shuffle đặc biệt hữu ích: nó làm lộ dependency vào thứ tự test, một họ hàng gần của dependency vào thứ tự map.
6.6 Defensive perturbation: tự phá mình trước
Đây là ý tưởng mình mượn từ MaybeReadByte. Nếu bạn biết hệ thống của mình có những chỗ người ta sẽ bám vào, hãy chủ động làm chúng bất ổn trong môi trường test, để test của bạn vỡ trước khi production của người khác vỡ.
Vài cách rẻ tiền và hiệu quả:
// io.Reader hợp lệ nhưng trả về số byte ngẫu nhiên mỗi lần đọc.
type jitterReader struct{ r io.Reader }
func (j jitterReader) Read(p []byte) (int, error) {
if len(p) > 1 && rand.Intn(2) == 0 {
p = p[:1+rand.Intn(len(p)-1)]
}
return j.r.Read(p)
}
Theo spec của io.Reader, reader được phép trả ít hơn len(p) byte. Rất nhiều code giả định một lần Read là đủ. Bọc reader bằng jitterReader trong test và bạn sẽ biết ngay mình thuộc nhóm nào.
Ba cách khác:
- Trong fake repository, trả row theo thứ tự ngẫu nhiên (nhưng vẫn tôn trọng
ORDER BYnếu query có). - Trong test build, shuffle key trước khi serialize để bắt code phụ thuộc thứ tự.
- Thêm jitter ±20% vào latency giả lập, để lộ các giả định về timing và retry.
Tip
Mục tiêu của perturbation không phải làm test flaky. Mục tiêu là biến một lớp bug “chỉ xảy ra trên production của người khác” thành một test đỏ trong CI của bạn. Nếu một test chỉ fail 1/20 lần, đừng retry cho nó xanh. Hãy đọc nó như một bằng chứng rằng bạn đang phụ thuộc vào một hành vi không được hứa.
6.7 Compatibility ratchet
Khi bạn buộc phải đổi một hành vi đã có người dùng, đừng đổi trực tiếp. Hãy dựng một ratchet:
- Announce: ghi vào changelog, đánh dấu deprecated trong doc, log một warning khi hành vi cũ được dùng.
- Gate: cho phép cả hai hành vi, chọn bằng config hoặc bằng version trong
go.mod. - Instrument: đo xem còn bao nhiêu phần trăm traffic dùng hành vi cũ. Không có số thì không có quyết định.
- Remove: chỉ xoá khi số đó bằng 0 trong ít nhất một chu kỳ release.
Go làm đúng như vậy với GODEBUG. Mỗi hành vi thay đổi có một tên trong bảng godebugs, có thể bật tắt, có documentation, và mặc định được chọn theo go directive trong go.mod. Đây là hạ tầng mà hầu hết chúng ta không có, nhưng ý tưởng thì copy được: mọi thay đổi hành vi phải có tên, có cờ, và có ngày hết hạn.
7. Trade-offs: bạn không thể thắng, chỉ có thể chọn
Nếu đọc đến đây và nghĩ “vậy là phải encapsulate mọi thứ, không export gì cả”, thì đó là kết luận sai và cũng là lý do nhiều API Go trở nên khó dùng một cách không cần thiết.
| Chiến lược | Lợi | Hại |
|---|---|---|
| Encapsulate tuyệt đối, không lộ gì | Tự do refactor tối đa | Người dùng phải dùng reflection hoặc fork; hiệu năng giảm |
| Trả interface thay vì struct | Che giấu implementation | Khó debug, khó mock, mất quyền truy cập field |
| Freeze mọi thứ đã lộ | Không bao giờ breaking | API phình to, mang theo sai lầm vĩnh viễn |
| Đổi liên tục, semver major | Sạch sẽ về lý thuyết | Hệ sinh thái phân mảnh, user bỏ đi |
Sự thật là chính các thư viện JSON hiệu năng cao tồn tại vì cái giá của việc encapsulate quá chặt. encoding/json đúng, an toàn, được bảo trì, nhưng reflection đắt. Người ta chấp nhận rủi ro của //go:linkname để đổi lấy 30% throughput. Đó là một trade-off hợp lý ở góc nhìn của một công ty có 1000 server. Nó chỉ không hợp lý khi bạn copy nó vào một service 3 server mà không biết mình đang vay gì.
Cách mình nghĩ về nó: bạn có một ngân sách bề mặt hữu hạn. Mỗi thứ bạn export, mỗi chuỗi bạn trả ra, mỗi field bạn đặt tên, là một khoản bạn phải trả lãi mãi mãi. Hãy tiêu ngân sách đó vào nơi đáng giá: error code, schema, tên field trên wire, status code. Đừng tiêu vào câu chữ trong message, thứ tự row, hay thời điểm một goroutine kết thúc.
Và hãy nhớ: thêm API là việc một chiều. Xoá API là việc gần như không thể. Vì vậy nguyên tắc duy nhất luôn đúng là: ship ít hơn, muộn hơn, và chỉ sau khi đã viết ra được semantics bằng lời.
8. Checklist
Trước khi merge một thay đổi chạm vào biên của service, mình chạy qua danh sách này:
- Có chỗ nào so sánh
err.Error()với string literal không? Nếu có, đổi sangerrors.Ishoặcerrors.Astrước khi làm gì khác. - Error trả ra client có
codeổn định tách khỏimessagekhông? - Mọi query trả list có
ORDER BYvới tiebreaker duy nhất chưa? - Có chỗ nào trả map hoặc iterate map ra ngoài mà không sort không?
- Struct response có tag tường minh cho mọi field, và có phải DTO riêng không?
- Có test nào đang pass nhờ thứ tự, nhờ timing, hoặc nhờ một lần
Readđủ byte không? - Nếu đổi hành vi, đã có cờ, có metric, có ngày xoá chưa?
- Có chỗ nào dùng
//go:linkname,unsafe, hoặc truy cập field quareflectmà mình chưa biết không? (go vetvàstaticcheckbắt được phần lớn.)
Kết
Quay lại dòng comment trong request.go. Điều làm mình ấn tượng không phải là kỹ thuật, mà là thái độ. Một team có quyền thay đổi bất cứ thứ gì trong ngôn ngữ của họ, nhưng họ chọn viết xuống rằng: “cái này giờ không còn thuộc về chúng tôi”.
Hyrum’s Law nghe như bi quan, nhưng thực ra nó là một lời nhắc rất tích cực về trách nhiệm. Nếu mọi hành vi quan sát được đều sẽ thành hợp đồng, thì việc chọn hành vi nào để lộ ra chính là thiết kế hệ thống. Bạn không kiểm soát được người dùng bám vào đâu. Bạn chỉ kiểm soát được mình đưa ra bao nhiêu thứ để bám.
Và nếu đang viết một API, hãy nhớ: bạn không sở hữu API của mình. Người dùng của bạn sở hữu nó. Việc của bạn là chọn thật kỹ xem họ sẽ sở hữu cái gì.
Đọc thêm
- Hyrum’s Law - bản gốc, ngắn và đáng đọc.
- Go 1.23 Release Notes, phần Linker - chính sách
//go:linknamemới. - golang/go#67401: cmd/link: lock down future uses of linkname - toàn bộ lý luận của Russ Cox, rất đáng đọc nguyên văn.
- Hyrum’s Law in Golang - bài viết đã truyền cảm hứng cho bài này.
- Go 1 compatibility promise - vì sao mọi thứ ở trên lại quan trọng với Go đến vậy.


