Bạn push một PR sửa vài dòng SQL. Mười lăm phút sau, job integration (orders) đỏ với dòng chữ quen thuộc: panic: test timed out after 10m0s. Không test nào fail. Chỉ là chúng chưa kịp chạy xong.
Phản xạ đầu tiên của mình là: Postgres chậm. Có 86 integration test trong package đó, mỗi test tạo bảng, insert, lock, rollback. Chắc tại ổ đĩa, tại fsync, tại migration nhiều. Mình đã tính chuyện tắt fsync trong container test.
Hoá ra Postgres không chậm. Postgres chỉ được khởi động 86 lần, và mỗi lần khởi động, mình vô tình bắt nó ngủ thêm 2 giây.
Đây là câu chuyện mình đo từng giây của một integration test, phát hiện mô hình “mỗi test một container” đắt ở đâu, chuyển sang mô hình “một container cho cả package, mỗi test một database copy từ template”, vì sao data dir của container test nằm trong RAM, và những chỗ mình đoán sai trên đường đi.
Tên module, schema và máy trong bài đều là tên minh hoạ. Số đo là số thật.
Mô hình cũ trông hợp lý như thế nào?
Monorepo Go của team có khoảng mười package chứa integration test cần Postgres thật. Mỗi package có một helper kiểu này:
func startPostgres(t *testing.T) string {
return testpg.Start(t, "orders-test-")
}Start làm đúng những gì tên nó nói: docker run postgres:16-alpine với port ngẫu nhiên, đăng ký t.Cleanup để docker rm -f, trả về DSN. Test gọi migration runner để dựng schema, rồi chạy.
Mô hình này có một ưu điểm không thể cãi: cách ly tuyệt đối. Mỗi test có server riêng, không ai làm bẩn ai, test nào kill server cũng không ảnh hưởng test khác. Với vài chục test thì chẳng ai để ý chi phí.
Nhưng package orders lớn dần lên 86 test. Cả ma trận CI, cộng lại, khởi động 178 container mỗi lần push.
Nhưng tại sao lại chậm đến thế?
Mình không tin cảm giác, nên đo. Bật timestamp và chạy một test rỗng, chỉ boot container, migrate, rồi thoát:
| Bước | Thời gian |
|---|---|
docker run + hỏi port | 0,17 s |
| Chờ Postgres nhận kết nối | 0,9 s |
| Migration runner ping hụt, ngủ chờ retry | 2,0 s |
| Chạy migration (hai schema) | 0,1 s |
docker rm -f | 0,2 s |
| Tổng mỗi test | ~2,5 s |
Hai giây ngủ kia là gì?
Image postgres mở port trước khi server sẵn sàng nhận kết nối. Start trả về ngay khi docker port có kết quả. Migration runner của team, vốn viết cho lúc deploy, có logic retry rất hợp lý: ping fail thì ngủ retryInterval = 2 * time.Second rồi thử lại. Trên production, 2 giây không là gì. Trong test, ping đầu tiên luôn hụt vì server chưa lên, nên mỗi test đều trả đúng 2 giây thuế.
Nhân với 86 test: 215 giây chỉ để boot và chờ. Test thật của cả package chạy trong phần còn lại, tức là vài chục giây.
Còn fsync? Mình đo luôn cho khỏi lăn tăn. Data dir của container đã nằm trên tmpfs. pgbench với fsync=on và fsync=off cho 3856 và 3748 tps, tức là không khác nhau trong sai số. Tắt fsync trên tmpfs là tối ưu một thứ không phải nút thắt. Bỏ.
Vậy sửa 2 giây ngủ là xong?
Sửa Start để poll kết nối tới khi server thật sự sẵn sàng thì cắt được 2 giây, mỗi test còn khoảng 1,3 giây. Package orders từ 254 giây xuống khoảng 130 giây. Tốt hơn, nhưng vẫn là 86 lần boot cho một việc giống hệt nhau: tạo một server trống, chạy cùng một bộ migration.
Câu hỏi đúng hơn là: thứ gì thực sự cần riêng cho mỗi test?
Không phải server. Không phải schema. Thứ cần riêng là dữ liệu: mỗi test muốn bắt đầu từ một database sạch, đúng schema, không có row nào của test trước.
Postgres có sẵn một cơ chế cho đúng nhu cầu đó: template database. CREATE DATABASE x TEMPLATE y copy toàn bộ file của y sang x, không chạy lại DDL. Trên tmpfs, mình đo được khoảng 20 ms cho một schema vài chục bảng. Bài viết của Gajus về chuẩn bị Postgres cho integration test đi đúng hướng này và là chỗ mình lấy ý tưởng.
Mô hình mới:
- Mỗi test binary (một package Go) boot một container, lần đầu có test xin database.
- Lần đầu một bộ schema được xin, helper tạo database
tpl_<schemas>, chạy migration lên HEAD, đánh dấu nó là template. - Mỗi test nhận một database mới copy từ template, dùng xong thì drop.
flowchart TD
accTitle: Luồng lấy database cho một integration test
accDescr: Test xin database. Nếu container chưa có thì boot một lần cho cả package. Nếu template cho bộ schema chưa có thì tạo và migrate một lần. Sau đó mỗi test nhận một database copy từ template và drop khi xong.
T[Test gọi Database t, orders] --> C{Container đã boot?}
C -- chưa --> B[docker run một lần<br/>chờ sẵn sàng ~1,3 s]
C -- rồi --> P
B --> P{Template tpl_orders đã có?}
P -- chưa --> M[CREATE DATABASE tpl_orders<br/>chạy migration lên HEAD<br/>ALTER ... IS_TEMPLATE true]
P -- rồi --> N
M --> N[CREATE DATABASE t_ab12 TEMPLATE tpl_orders<br/>~20 ms]
N --> R[Test chạy]
R --> D[t.Cleanup: DROP DATABASE t_ab12 WITH FORCE]Phần đáng chú ý là hai nhánh “chưa” chỉ đi qua một lần cho cả package. 85 test còn lại chỉ đi đường bên phải: một câu CREATE DATABASE, một câu DROP.
Đi xuống technical một chút
API cho test gọn lại thành hai hàm:
// Copy của một template đã migrate lên HEAD. Up() trên nó trả về changed=false.
func Database(t *testing.T, schemas ...string) string
// Copy của template0: không schema, cho test tự migrate từ số 0.
func EmptyDatabase(t *testing.T) stringHelper cũ trong mỗi package chỉ đổi một dòng:
func startPostgres(t *testing.T) string {
return testschema.Database(t, "counters", "orders")
}Bên trong, có bốn ràng buộc của Postgres và Go mà mình phải tôn trọng, và mỗi cái đều là một lỗi tiềm ẩn nếu bỏ qua.
Thứ nhất, CREATE DATABASE ... TEMPLATE từ chối khi còn session nào đang nối vào template. Migration runner đóng kết nối sau khi chạy, nhưng “đóng” trong pool nghĩa là trả về pool, chưa chắc đã ngắt ở phía server. Nên sau khi migrate xong, helper cắt mọi session còn bám vào template rồi mới đánh dấu:
_, _ = admin.Exec(ctx,
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname = $1 AND pid <> pg_backend_pid()`, template)
_, _ = admin.Exec(ctx, "ALTER DATABASE "+ident(template)+" IS_TEMPLATE true")Thứ hai, test quên đóng pool không được làm hỏng test sau. DROP DATABASE thường từ chối khi còn kết nối. Từ Postgres 13 có WITH (FORCE), ngắt kết nối rồi drop. Mình dùng nó trong t.Cleanup, nên một test rò pool chỉ tự hại nó, không hại package.
Thứ ba, mọi thao tác admin đi qua một mutex và một kết nối admin. Hôm nay test chạy tuần tự, nhưng ngày nào đó có người bật t.Parallel() thì hai CREATE DATABASE ... TEMPLATE cùng lúc từ một template vẫn phải an toàn. Lỗi khi build template cũng được nhớ theo key: migration hỏng thì mọi test phụ thuộc fail với cùng một thông báo, thay vì test đầu fail còn 85 test sau fail vì lý do khác.
Thứ tư, Go không có hook “package chạy xong”. Container sống tới khi process go test thoát, và nếu mình docker run --rm thì nó vẫn là process con đã detach, không tự chết. Cách duy nhất chạy code sau khi mọi test xong là TestMain:
//go:build integration
func TestMain(m *testing.M) { os.Exit(testpg.Main(m)) }Main chạy m.Run() rồi docker rm -fv container. Mỗi package dùng helper có thêm đúng một file này. Script CI vẫn quét container theo label ở trap EXIT làm lưới an toàn cho trường hợp go test bị kill giữa chừng.
Data dir nằm trong RAM
Một chi tiết nhỏ trong lệnh docker run của helper quyết định khá nhiều con số ở trên:
args := []string{
"run", "-d", "--name", name,
"--tmpfs", "/var/lib/postgresql/data:rw,noexec,nosuid,size=512m",
"-e", "POSTGRES_HOST_AUTH_METHOD=trust",
"-p", "127.0.0.1::5432",
"postgres:16-alpine",
}--tmpfs mount một filesystem nằm hoàn toàn trong RAM vào đúng chỗ Postgres ghi dữ liệu. Không volume, không ghi xuống đĩa, container bị xoá là dữ liệu biến mất cùng nó. Với dữ liệu test thì đó chính xác là điều mình muốn: không có gì đáng giữ, và không có gì để dọn.
Vì sao chuyện này quan trọng với mô hình template? CREATE DATABASE ... TEMPLATE là copy file: Postgres đọc toàn bộ thư mục của template và ghi ra thư mục mới. Trên tmpfs, copy là chuyện của memory bandwidth, không phải của ổ đĩa, nên mới ra con số 20 ms cho một schema vài chục bảng. DROP DATABASE cũng vậy: xoá thư mục trong RAM, khoảng 20 ms.
Nó cũng giải thích vì sao fsync không phải nút thắt. Mỗi commit, Postgres gọi fsync lên WAL để chắc dữ liệu đã nằm trên thiết bị bền. Trên tmpfs, “thiết bị” là RAM, fsync gần như trả về ngay. Đó là lý do pgbench với fsync=on và fsync=off cho kết quả như nhau. Nhờ vậy mình giữ nguyên mọi cấu hình mặc định của Postgres trong test: không fsync=off, không synchronous_commit=off, không full_page_writes=off. Ít thứ khác production hơn, ít bất ngờ hơn, mà không mất gì.
Ba cờ còn lại cũng có lý do:
size=512mlà trần, không phải mức chiếm dụng. tmpfs chỉ tốn RAM theo lượng dữ liệu thật sự ghi vào. Trong mô hình mới, trần này được chia cho mọi database của một test binary cộng với các template (template không bị drop cho tới khi container chết). Bộ test của mình còn xa mức đó, nhưng một test ghi vài trăm MB thì phải có container riêng.noexec,nosuidlà vệ sinh: thư mục dữ liệu không có lý do gì để chạy binary hay set-uid.-p 127.0.0.1::5432để Docker chọn port trống, nhiều container vẫn sống chung một máy mà không đụng nhau, và chỉ nghe trên loopback.
Có một điểm mình muốn nói thật: hai package tự viết docker run từ trước khi có helper chung không có tmpfs, tức là suốt thời gian đó chúng ghi xuống ổ đĩa của runner rồi xoá. Không ai để ý vì chúng ít test. Khi gom về helper chung, chúng tự nhiên có tmpfs và label để script dọn. Đây là kiểu lợi ích “miễn phí” của việc chỉ có một chỗ khởi động container.
Giới hạn của cách này là hiển nhiên: mọi thứ liên quan tới I/O thật (checkpoint chậm, đĩa đầy, WAL replay sau crash) không được test. Với integration test kiểm chứng logic, lock và migration thì đó không phải mục tiêu. Với test về độ bền dữ liệu thì tmpfs là sai công cụ.
Những chỗ mình đoán sai
Chuyển 12 package đi qua khá êm, trừ hai chỗ mình tự tin quá sớm.
Đếm sót test cần database trống. Một số test kiểm chứng migration bằng cách assert Up() trả về changed=true:
if changed, err := runner.Up(); err != nil || !changed {
t.Fatalf("migration up = changed %v, err %v", changed, err)
}Trên một database đã migrate lên HEAD, Up() trả changed=false và test fail. Những test này cần EmptyDatabase. Mình đọc code, đếm được 6 chỗ. Chạy lần đầu, ba package đỏ với migration up = changed false. grep -rn '!changed' cho ra 18 chỗ. Bài học nhỏ: khi tiêu chí là một pattern cú pháp, dùng grep, đừng dùng mắt.
Cái gì là “cả cluster”, cái gì là “theo database”. Container giờ dùng chung, nên mình phải rà lại những gì test đọc mà không thuộc về database của nó. pg_stat_activity liệt kê session của mọi database trong cluster. Một test đếm session đang chờ advisory lock, trước đây đúng vì cluster chỉ có một database, giờ có thể đếm nhầm session của test khác. Sửa bằng cách thêm datname = current_database().
Rồi mình viết vào rule: “advisory lock là phạm vi cả cluster, hai test khác database cùng key sẽ chờ nhau”. Sai. Reviewer chỉ ra Postgres gắn database id vào lock tag, nên advisory lock là theo database, không rò giữa các database. Cùng “một thứ trong cluster”, pg_stat_activity thì nhìn xuyên database còn advisory lock thì không. Mình đã sửa doc trước khi ai đó tin lời mình mà đi thêm UUID vào mọi lock key.
Làm sao biết nó đã ổn?
Đo cùng máy, cùng lệnh make test-integration-<scope>, trước và sau:
| Package | Trước | Sau |
|---|---|---|
| orders (86 test) | 254,6 s | 37,3 s |
| platform + migrations | 102,0 s | 23,8 s |
| chat | 42,1 s | 8,4 s |
| directory | 48,5 s | 5,3 s |
| media | 19,2 s | 3,9 s |
| catalog | 23,3 s | 4,7 s |
| task | 9,3 s | 3,4 s |
| projects | 3,8 s | 2,5 s |
| vouchers | 6,3 s | 4,7 s |
| Tổng | 509 s | 94 s |
Số container boot cho cả bộ: 12 thay vì 178. Sau mỗi lần chạy, docker ps -a --filter label=... trống.
Trên CI, job integration (orders) từ 3 phút 48 giây (và thỉnh thoảng chạm trần 10 phút trên máy chậm) xuống 51 giây kể cả checkout và setup Go.
Helper có bộ test riêng để giữ các ràng buộc trên: template copy đúng schema, hai copy cách ly nhau, drop được database dù còn pool đang mở, prepare hỏng làm mọi test phụ thuộc fail cùng lý do.
Khi nào không nên làm vậy?
Mô hình này đổi cách ly ở mức server lấy cách ly ở mức database. Có ba giá phải trả.
- Một test làm hỏng server thì cả package đỏ. Test nào cần kill Postgres, đổi
postgresql.conf, tạo role hay tablespace thì vẫn nên dùngStartvà ghi rõ lý do. Trong repo của mình chưa có test nào như vậy. - Chuỗi migration từ số 0 chỉ chạy một lần mỗi package (lúc build template) thay vì mỗi test. Vẫn là một lần kiểm chứng đầy đủ mỗi job, các test peel
Down(n)rồiUp()vẫn kiểm reversibility, và 18 test kia vẫn migrate từ trống. t.Parallel()chưa bật. Helper đã chịu được, nhưng còn vài test có assertion về thời gian và một test đọcpg_stat_activity. Mình để việc đó cho một PR riêng, sau khi có số đo CI ổn định. Tối ưu hai thứ cùng lúc thì không biết cái nào có tác dụng.
Cũng cần nói thẳng: nếu package của bạn có 5 test, mô hình cũ vẫn ổn. Chi phí 2,5 giây mỗi test chỉ thành vấn đề khi nhân với số lớn.
Chốt lại
Cả bài gói lại trong một câu: thứ mình tưởng “cần riêng cho mỗi test” thật ra “dùng chung được”, còn thứ thật sự cần riêng thì Postgres cho copy trong 20 ms.
- Integration test không cần server riêng. Nó cần dữ liệu riêng. Postgres cho copy database bằng file trong 20 ms, hãy trả tiền cho đúng thứ đó.
- Trước khi tối ưu database, đo xem thời gian nằm ở đâu. Của mình nằm ở 2 giây ngủ chờ retry, không nằm ở
fsync. - Khi gom nhiều thứ vào một chỗ dùng chung, liệt kê rõ cái gì trở thành “chung”:
pg_stat_activitythì có, advisory lock thì không. Và để reviewer kiểm tra lại lời khẳng định của bạn. - Dữ liệu test không cần bền. Đặt data dir vào tmpfs, giữ nguyên cấu hình Postgres mặc định, và để container chết là dọn xong.