diff --git a/common/middleware/sentinel.go b/common/middleware/sentinel.go index da6e5e89..58b897a7 100644 --- a/common/middleware/sentinel.go +++ b/common/middleware/sentinel.go @@ -1,29 +1,54 @@ package middleware import ( + "net/http" + "github.com/alibaba/sentinel-golang/core/system" sentinel "github.com/alibaba/sentinel-golang/pkg/adapters/gin" "github.com/gin-gonic/gin" log "github.com/go-admin-team/go-admin-core/v2/logger" + + "go-admin/config" ) // Sentinel 限流 +// +// The threshold comes from extend.ratelimit.inboundqps; see config.RateLimit +// for the values it accepts. func Sentinel() gin.HandlerFunc { + qps := config.ExtConfig.RateLimit.Threshold() + if qps <= 0 { + log.Info("rate limit disabled by extend.ratelimit.inboundqps") + return func(c *gin.Context) { c.Next() } + } + if _, err := system.LoadRules([]*system.Rule{ { MetricType: system.InboundQPS, - TriggerCount: 200, - Strategy: system.BBR, + TriggerCount: qps, + // InboundQPS is compared against TriggerCount directly - the + // adaptive strategy is only consulted for Load and CpuUsage. BBR + // stood here and read as if the limit adapted to the machine, which + // it never did. + Strategy: system.NoAdaptive, }, }); err != nil { log.Fatalf("Unexpected error: %+v", err) } + + log.Infof("rate limit: %.0f inbound req/s", qps) + return sentinel.SentinelMiddleware( sentinel.WithBlockFallback(func(ctx *gin.Context) { - ctx.AbortWithStatusJSON(200, map[string]interface{}{ + // 429, not 200. Everything that reads the status line rather than + // the body counts a 200 as served: load balancers, metrics, + // client-side retry, and load tests - a benchmark against the old + // behaviour reported the limiter's own rejections as successful + // traffic and overstated throughput by more than tenfold. + ctx.AbortWithStatusJSON(http.StatusTooManyRequests, map[string]interface{}{ "msg": "too many request; the quota used up!", - "code": 500, + "code": http.StatusTooManyRequests, }) }), ) diff --git a/common/middleware/sentinel_test.go b/common/middleware/sentinel_test.go new file mode 100644 index 00000000..35d521c4 --- /dev/null +++ b/common/middleware/sentinel_test.go @@ -0,0 +1,107 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/alibaba/sentinel-golang/core/system" + "github.com/gin-gonic/gin" + + "go-admin/config" +) + +// serve builds a router with the limiter in front of a handler that always +// succeeds, so any non-200 comes from the limiter. +func serve(t *testing.T) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(Sentinel()) + r.GET("/ping", func(c *gin.Context) { c.Status(http.StatusOK) }) + return r +} + +func get(t *testing.T, r *gin.Engine) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/ping", nil)) + return w +} + +// TestSentinelRejectsWithTooManyRequests pins the status code. A rejected +// request used to answer 200 with the failure only in the body, so every layer +// that reads the status line - load balancers, metrics, client retry, load +// tests - counted it as served. +func TestSentinelRejectsWithTooManyRequests(t *testing.T) { + one := 1.0 + config.ExtConfig.RateLimit = config.RateLimit{InboundQPS: &one} + t.Cleanup(func() { + config.ExtConfig.RateLimit = config.RateLimit{} + _ = system.ClearRules() + }) + + r := serve(t) + + var rejected *httptest.ResponseRecorder + for i := 0; i < 20; i++ { + if w := get(t, r); w.Code != http.StatusOK { + rejected = w + break + } + } + if rejected == nil { + t.Fatal("a limit of 1 req/s let 20 requests through; the limiter is not engaged") + } + if rejected.Code != http.StatusTooManyRequests { + t.Errorf("rejected with %d, want %d", rejected.Code, http.StatusTooManyRequests) + } + + // The body's code must agree with the status line; they disagreed before. + var body struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if err := json.Unmarshal(rejected.Body.Bytes(), &body); err != nil { + t.Fatalf("rejection body is not json: %v", err) + } + if body.Code != http.StatusTooManyRequests { + t.Errorf("body code = %d, want %d", body.Code, http.StatusTooManyRequests) + } + if body.Msg == "" { + t.Error("rejection carries no message") + } +} + +// TestSentinelDisabledByZero covers the escape hatch: a deployment behind its +// own gateway has no use for a second limiter. +// +// It asserts on the loaded rules rather than on traffic. Sentinel measures QPS +// over a sliding window, so a burst issued inside one bucket is not counted +// before the bucket closes - a few hundred requests sail past a threshold of +// 200 in a test, and "no request was rejected" would pass whether or not the +// limiter is disabled. Whether a rule was installed at all does not depend on +// timing. +func TestSentinelDisabledByZero(t *testing.T) { + if err := system.ClearRules(); err != nil { + t.Fatal(err) + } + zero := 0.0 + config.ExtConfig.RateLimit = config.RateLimit{InboundQPS: &zero} + t.Cleanup(func() { + config.ExtConfig.RateLimit = config.RateLimit{} + _ = system.ClearRules() + }) + + r := serve(t) + if rules := system.GetRules(); len(rules) != 0 { + t.Errorf("limiter disabled but %d rule(s) were loaded: %+v", len(rules), rules) + } + + for i := 0; i < 500; i++ { + if w := get(t, r); w.Code != http.StatusOK { + t.Fatalf("request %d got %d with the limiter disabled", i, w.Code) + } + } +} diff --git a/config/extend.go b/config/extend.go index e8d6daa6..2caf4c01 100644 --- a/config/extend.go +++ b/config/extend.go @@ -12,6 +12,39 @@ var ExtConfig Extend type Extend struct { AMap AMap // 这里配置对应配置文件的结构即可 FileStore FileStore + RateLimit RateLimit +} + +// DefaultInboundQPS is the limit applied when nothing is configured. It is the +// value that used to be hard-coded in the middleware, so an existing deployment +// that adds nothing to settings.yml keeps the behaviour it already had. +const DefaultInboundQPS = 200 + +// RateLimit 全局入站限流。 +// +// extend: +// ratelimit: +// inboundqps: 200 # 每秒入站请求上限;填 0 关闭限流 +// +// The threshold used to live in common/middleware/sentinel.go as a constant, +// which made 200 QPS the ceiling of every deployment with nothing in the +// configuration to reveal it. +type RateLimit struct { + // InboundQPS caps inbound requests per second across the process. + // + // Absent means DefaultInboundQPS, zero disables the limiter, and a positive + // value is the threshold. The pointer is what separates "not configured" + // from "configured to zero" - the two need different answers and a plain + // float64 cannot tell them apart. + InboundQPS *float64 +} + +// Threshold reports the limit to apply. Zero means no limiting. +func (r RateLimit) Threshold() float64 { + if r.InboundQPS == nil { + return DefaultInboundQPS + } + return *r.InboundQPS } type AMap struct { diff --git a/config/extend_test.go b/config/extend_test.go index 230174f4..62b8f5cb 100644 --- a/config/extend_test.go +++ b/config/extend_test.go @@ -14,3 +14,21 @@ func TestObjectStoreConfigured(t *testing.T) { t.Fatal("partial store reported as configured") } } + +func TestRateLimitThreshold(t *testing.T) { + // Absent is the case an existing settings.yml hits after an upgrade: it has + // no ratelimit section, and must keep the limit it always had. + if got := (RateLimit{}).Threshold(); got != DefaultInboundQPS { + t.Errorf("unconfigured limit = %v, want the default %v", got, DefaultInboundQPS) + } + + zero := 0.0 + if got := (RateLimit{InboundQPS: &zero}).Threshold(); got != 0 { + t.Errorf("explicit zero = %v, want 0 so the limiter can be turned off", got) + } + + custom := 1500.0 + if got := (RateLimit{InboundQPS: &custom}).Threshold(); got != custom { + t.Errorf("configured limit = %v, want %v", got, custom) + } +} diff --git a/config/settings.full.yml b/config/settings.full.yml index a1fdacce..c3067364 100644 --- a/config/settings.full.yml +++ b/config/settings.full.yml @@ -56,6 +56,13 @@ settings: extend: # 扩展项使用说明 demo: name: data + # rateLimit 全局入站限流。不配置时为 200 QPS,与此前写死在 + # common/middleware/sentinel.go 里的值一致,升级不会改变行为。 + # 填 0 关闭限流——部署在自带限流的网关后面时用得上。 + # 超出阈值的请求返回 HTTP 429(旧版本返回 200,只在 body 里写 code:500, + # 会被负载均衡、监控和压测统计成成功)。 + rateLimit: + inboundQPS: 200 # fileStore 对象存储。上传接口的 source 参数决定走哪一家: # source=1 只存本地,source=2 阿里云 OSS,source=3 七牛 Kodo # 没有填的那一家在被请求时会返回明确错误,不会静默存到别处。 diff --git a/config/settings.yml b/config/settings.yml index 6891632c..058aabc0 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -48,6 +48,13 @@ settings: extend: # 扩展项使用说明 demo: name: data + # rateLimit 全局入站限流。不配置时为 200 QPS,与此前写死在 + # common/middleware/sentinel.go 里的值一致,升级不会改变行为。 + # 填 0 关闭限流——部署在自带限流的网关后面时用得上。 + # 超出阈值的请求返回 HTTP 429(旧版本返回 200,只在 body 里写 code:500, + # 会被负载均衡、监控和压测统计成成功)。 + rateLimit: + inboundQPS: 200 cache: # redis: # addr: 127.0.0.1:6379