Files
go-admin/test/loadtest/loadtest_test.go
zhangwenjian ed74623a73 test: add an end-to-end load test harness
Skipped unless GOADMIN_BENCH_ADDR points at a running server, so `go test
./...` is unaffected.

Reports latency percentiles rather than an average, which is what capacity
planning needs, and a status-code distribution - that last part is how the rate
limiter's 200-on-rejection was found, since throughput alone looked excellent
while nothing reached a handler.

Includes a routing-floor control case. When a business endpoint matches it, the
measurement has stopped describing the endpoint and started describing the
transport, or the load generator when both share a machine.
2026-08-28 19:42:38 +08:00

368 lines
10 KiB
Go

// Package loadtest measures what one go-admin process sustains over HTTP.
//
// It is skipped unless GOADMIN_BENCH_ADDR points at a running server, so
// `go test ./...` is unaffected. Start a server and run:
//
// GOADMIN_BENCH_ADDR=http://127.0.0.1:8000 go test ./test/loadtest/ -v -run TestLoadProfile
//
// Unlike a Go benchmark this reports latency percentiles, which is what
// capacity planning needs: an average hides the tail that users actually feel.
//
// Two caveats when reading the numbers. The load generator runs on the same
// machine as the server unless GOADMIN_BENCH_ADDR is remote, so both compete
// for the same cores - a split deployment measures higher. And the figures
// describe the configured backend: sqlite and MySQL differ by more than the
// framework does.
package loadtest
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
const (
addrEnv = "GOADMIN_BENCH_ADDR"
tokenEnv = "GOADMIN_BENCH_TOKEN"
userEnv = "GOADMIN_BENCH_USER"
passEnv = "GOADMIN_BENCH_PASS"
// Each concurrency level runs for this long. Long enough to get past
// connection setup and let the scheduler settle, short enough that the
// whole sweep stays interactive.
levelDuration = 3 * time.Second
)
// concurrencyLevels sweeps from a single client to well past core count, so
// the point where added concurrency stops buying throughput is visible rather
// than assumed. Peak throughput and peak concurrency are not the same number:
// past the peak a server takes more work than it can finish and both
// throughput and latency get worse, so the sweep has to bracket the turn
// rather than stop at the top.
//
// GOADMIN_BENCH_LEVELS overrides it, comma separated.
var concurrencyLevels = parseLevels(os.Getenv("GOADMIN_BENCH_LEVELS"), []int{1, 2, 4, 8, 16, 32, 64, 128, 256, 512})
func parseLevels(spec string, fallback []int) []int {
if spec == "" {
return fallback
}
out := make([]int, 0, 8)
for _, f := range strings.Split(spec, ",") {
n, err := strconv.Atoi(strings.TrimSpace(f))
if err != nil || n <= 0 {
continue
}
out = append(out, n)
}
if len(out) == 0 {
return fallback
}
return out
}
func addr(t testing.TB) string {
t.Helper()
a := os.Getenv(addrEnv)
if a == "" {
t.Skipf("%s not set; skipping load test", addrEnv)
}
return a
}
// newClient returns a client whose pool is large enough that the generator
// does not become the bottleneck it is trying to measure.
func newClient(maxConns int) *http.Client {
return &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
MaxIdleConns: maxConns * 2,
MaxIdleConnsPerHost: maxConns * 2,
MaxConnsPerHost: maxConns * 2,
IdleConnTimeout: 90 * time.Second,
DisableCompression: true,
},
}
}
// result is one completed request.
type result struct {
latency time.Duration
err bool
status int
}
// report is the summary of one concurrency level.
type report struct {
concurrency int
total int64
failed int64
elapsed time.Duration
p50, p95, p99, max time.Duration
statuses map[int]int64
}
func (r report) qps() float64 {
if r.elapsed == 0 {
return 0
}
return float64(r.total) / r.elapsed.Seconds()
}
func (r report) String() string {
codes := make([]int, 0, len(r.statuses))
for c := range r.statuses {
codes = append(codes, c)
}
sort.Ints(codes)
dist := make([]string, 0, len(codes))
for _, c := range codes {
dist = append(dist, fmt.Sprintf("%d:%d", c, r.statuses[c]))
}
return fmt.Sprintf("c=%-4d %9.0f req/s p50=%-9s p95=%-9s p99=%-9s max=%-9s failed=%-7d %s",
r.concurrency, r.qps(),
r.p50.Round(time.Microsecond), r.p95.Round(time.Microsecond),
r.p99.Round(time.Microsecond), r.max.Round(time.Microsecond), r.failed,
strings.Join(dist, " "))
}
// drive runs `concurrency` workers against req for levelDuration and collects
// every latency. Bodies are drained and closed - skipping that silently caps
// throughput at the point connections stop being reused.
func drive(t testing.TB, concurrency int, want int, mk func() *http.Request) report {
t.Helper()
client := newClient(concurrency)
defer client.CloseIdleConnections()
ctx, cancel := context.WithTimeout(context.Background(), levelDuration)
defer cancel()
var (
mu sync.Mutex
samples []time.Duration
statuses = map[int]int64{}
failed atomic.Int64
total atomic.Int64
wg sync.WaitGroup
)
start := time.Now()
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func() {
defer wg.Done()
local := make([]time.Duration, 0, 1024)
localStatus := map[int]int64{}
for ctx.Err() == nil {
req := mk()
t0 := time.Now()
resp, err := client.Do(req.WithContext(ctx))
d := time.Since(t0)
if err != nil {
if ctx.Err() != nil {
break
}
failed.Add(1)
total.Add(1)
continue
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
localStatus[resp.StatusCode]++
if resp.StatusCode != want {
failed.Add(1)
}
total.Add(1)
local = append(local, d)
}
mu.Lock()
samples = append(samples, local...)
for code, n := range localStatus {
statuses[code] += n
}
mu.Unlock()
}()
}
wg.Wait()
elapsed := time.Since(start)
sort.Slice(samples, func(i, j int) bool { return samples[i] < samples[j] })
r := report{
concurrency: concurrency,
total: total.Load(),
failed: failed.Load(),
elapsed: elapsed,
statuses: statuses,
}
if n := len(samples); n > 0 {
r.p50 = samples[n*50/100]
r.p95 = samples[min(n*95/100, n-1)]
r.p99 = samples[min(n*99/100, n-1)]
r.max = samples[n-1]
}
return r
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// login obtains a token. GOADMIN_BENCH_TOKEN short-circuits it, which is how a
// server in prod mode is reached - there the login endpoint demands a captcha.
func login(t testing.TB, base string) string {
t.Helper()
if tok := os.Getenv(tokenEnv); tok != "" {
return tok
}
user, pass := os.Getenv(userEnv), os.Getenv(passEnv)
if user == "" {
user, pass = "admin", "123456"
}
body, _ := json.Marshal(map[string]string{
"username": user,
"password": pass,
"code": "0",
"uuid": "0",
})
resp, err := http.Post(base+"/api/v1/login", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("login request failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("login returned %d: %s\n(a server in prod mode requires a captcha; set %s instead)",
resp.StatusCode, raw, tokenEnv)
}
var out struct {
Token string `json:"token"`
}
if err := json.Unmarshal(raw, &out); err != nil || out.Token == "" {
t.Fatalf("no token in login response: %s", raw)
}
return out.Token
}
// TestLoadProfile sweeps concurrency against three endpoints chosen for what
// they isolate:
//
// - captcha: no auth, no business query. The routing and image-generation
// floor.
// - dept list: the full authenticated path - JWT parse, casbin check, data
// permission scope, database read. This is what a real page costs.
// - login: bcrypt. Deliberately slow, and the one endpoint whose ceiling is
// set by design rather than by the framework.
func TestLoadProfile(t *testing.T) {
base := addr(t)
token := login(t, base)
cases := []struct {
name string
want int
mk func() *http.Request
}{
{
// The control. An unrouted path exercises the HTTP stack, gin's
// tree lookup and nothing else, so it bounds every other row here.
// When a business endpoint reaches this number, the measurement has
// stopped describing the endpoint and started describing the
// transport - or the load generator, when both share a machine.
name: "404 (http+routing floor)",
want: 404,
mk: func() *http.Request {
req, _ := http.NewRequest(http.MethodGet, base+"/api/v1/__no_such_route__", nil)
return req
},
},
{
// The framework on its own: global middleware chain, route lookup,
// and a handler that only sets a status. No database, no cache.
// Against the 404 row this isolates what the chain costs; against
// the rows below it, what the business path adds.
//
// Numbers from any endpoint that touches a database describe the
// database, the driver and the pool as much as the framework - the
// MySQL sweeps here moved from collapsing at c=64 to 19k req/s at
// c=512 on a pool setting alone, with the framework untouched.
name: "health (framework only, no db)",
want: 200,
mk: func() *http.Request {
req, _ := http.NewRequest(http.MethodGet, base+"/api/v1/health", nil)
return req
},
},
{
name: "captcha (no auth)",
want: 200,
mk: func() *http.Request {
req, _ := http.NewRequest(http.MethodGet, base+"/api/v1/captcha", nil)
return req
},
},
{
name: "dept list (jwt+casbin+db)",
want: 200,
mk: func() *http.Request {
req, _ := http.NewRequest(http.MethodGet, base+"/api/v1/dept", nil)
req.Header.Set("Authorization", "Bearer "+token)
return req
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
for _, c := range concurrencyLevels {
t.Log(drive(t, c, tc.want, tc.mk))
}
})
}
}
// TestLoginThroughput is separated because bcrypt saturates the CPU: running
// it alongside the others would distort them. It also writes a login-log row
// per attempt when logger.enableddb is on, so the number moves with that
// setting.
func TestLoginThroughput(t *testing.T) {
base := addr(t)
if os.Getenv(tokenEnv) != "" {
t.Skip("token supplied; login endpoint presumably needs a captcha")
}
user, pass := os.Getenv(userEnv), os.Getenv(passEnv)
if user == "" {
user, pass = "admin", "123456"
}
body, _ := json.Marshal(map[string]string{
"username": user, "password": pass, "code": "0", "uuid": "0",
})
mk := func() *http.Request {
req, _ := http.NewRequest(http.MethodPost, base+"/api/v1/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
return req
}
for _, c := range []int{1, 4, 8, 16, 32, 64, 128} {
t.Log(drive(t, c, http.StatusOK, mk))
}
}