feat:添加压测工具脚本
This commit is contained in:
parent
a8936113a5
commit
f30655ec64
@ -14,8 +14,13 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
codeberg.org/go-fonts/liberation v0.5.0 // indirect
|
||||||
|
codeberg.org/go-latex/latex v0.2.0 // indirect
|
||||||
|
codeberg.org/go-pdf/fpdf v0.11.1 // indirect
|
||||||
|
git.sr.ht/~sbinet/gg v0.7.0 // indirect
|
||||||
github.com/RoaringBitmap/roaring v1.2.3 // indirect
|
github.com/RoaringBitmap/roaring v1.2.3 // indirect
|
||||||
github.com/Workiva/go-datastructures v1.0.52 // indirect
|
github.com/Workiva/go-datastructures v1.0.52 // indirect
|
||||||
|
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b // indirect
|
||||||
github.com/alibabacloud-go/debug v1.0.1 // indirect
|
github.com/alibabacloud-go/debug v1.0.1 // indirect
|
||||||
github.com/alibabacloud-go/tea v1.2.2 // indirect
|
github.com/alibabacloud-go/tea v1.2.2 // indirect
|
||||||
github.com/apache/dubbo-getty v1.4.10 // indirect
|
github.com/apache/dubbo-getty v1.4.10 // indirect
|
||||||
@ -38,6 +43,7 @@ require (
|
|||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
|
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||||
@ -92,12 +98,14 @@ require (
|
|||||||
go.uber.org/atomic v1.10.0 // indirect
|
go.uber.org/atomic v1.10.0 // indirect
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
go.uber.org/multierr v1.10.0 // indirect
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
|
golang.org/x/image v0.30.0 // indirect
|
||||||
golang.org/x/mod v0.30.0 // indirect
|
golang.org/x/mod v0.30.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.47.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.39.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
golang.org/x/tools v0.39.0 // indirect
|
golang.org/x/tools v0.39.0 // indirect
|
||||||
|
gonum.org/v1/plot v0.17.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
||||||
google.golang.org/grpc v1.78.0 // indirect
|
google.golang.org/grpc v1.78.0 // indirect
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
|
|||||||
@ -31,9 +31,17 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
|
|||||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||||
|
codeberg.org/go-fonts/liberation v0.5.0 h1:SsKoMO1v1OZmzkG2DY+7ZkCL9U+rrWI09niOLfQ5Bo0=
|
||||||
|
codeberg.org/go-fonts/liberation v0.5.0/go.mod h1:zS/2e1354/mJ4pGzIIaEtm/59VFCFnYC7YV6YdGl5GU=
|
||||||
|
codeberg.org/go-latex/latex v0.2.0 h1:Ol/a6VHY06N+5gPfewswymoRb5ZcKDXWVaVegcx4hbI=
|
||||||
|
codeberg.org/go-latex/latex v0.2.0/go.mod h1:VJAwQir7/T8LZxj7xAPivISKiVOwkMpQ8bTuPQ31X0Y=
|
||||||
|
codeberg.org/go-pdf/fpdf v0.11.1 h1:U8+coOTDVLxHIXZgGvkfQEi/q0hYHYvEHFuGNX2GzGs=
|
||||||
|
codeberg.org/go-pdf/fpdf v0.11.1/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU=
|
||||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
dubbo.apache.org/dubbo-go/v3 v3.3.1 h1:tYySwA8FsE6u6TfgvMmjrBrzu9kqhgMrQ7CJXW6deO8=
|
dubbo.apache.org/dubbo-go/v3 v3.3.1 h1:tYySwA8FsE6u6TfgvMmjrBrzu9kqhgMrQ7CJXW6deO8=
|
||||||
dubbo.apache.org/dubbo-go/v3 v3.3.1/go.mod h1:va59d5/A3OD41YyxJ1cijhKV5ABV1OZxgjncWV+K8Ys=
|
dubbo.apache.org/dubbo-go/v3 v3.3.1/go.mod h1:va59d5/A3OD41YyxJ1cijhKV5ABV1OZxgjncWV+K8Ys=
|
||||||
|
git.sr.ht/~sbinet/gg v0.7.0 h1:YmNf7YKd7diDMTPm86hZa1EM3pbkOyD/zzjl0LZUdNM=
|
||||||
|
git.sr.ht/~sbinet/gg v0.7.0/go.mod h1:VYeli15tpMM4EvqlivlVbbyvWZlOU+EZn4XZmfBGUdM=
|
||||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
@ -51,7 +59,11 @@ github.com/Workiva/go-datastructures v1.0.52 h1:PLSK6pwn8mYdaoaCZEMsXBpBotr4HHn9
|
|||||||
github.com/Workiva/go-datastructures v1.0.52/go.mod h1:Z+F2Rca0qCsVYDS8z7bAGm8f3UkzuWYS/oBZz5a7VVA=
|
github.com/Workiva/go-datastructures v1.0.52/go.mod h1:Z+F2Rca0qCsVYDS8z7bAGm8f3UkzuWYS/oBZz5a7VVA=
|
||||||
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5 h1:rFw4nCn9iMW+Vajsk51NtYIcwSTkXr+JGrMd36kTDJw=
|
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5 h1:rFw4nCn9iMW+Vajsk51NtYIcwSTkXr+JGrMd36kTDJw=
|
||||||
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
|
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=
|
||||||
|
github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY=
|
||||||
|
github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk=
|
||||||
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
|
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
|
||||||
|
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
|
||||||
|
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
|
||||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||||
@ -258,6 +270,7 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69
|
|||||||
github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
|
github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
|
github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
|
||||||
@ -831,6 +844,8 @@ golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8H
|
|||||||
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
|
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
|
||||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||||
|
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
@ -1090,6 +1105,7 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc
|
|||||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
golang.org/x/tools v0.0.0-20201014170642-d1624618ad65/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
|
golang.org/x/tools v0.0.0-20201014170642-d1624618ad65/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
|
||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||||
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||||
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||||
@ -1109,6 +1125,8 @@ gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E
|
|||||||
gonum.org/v1/netlib v0.0.0-20181029234149-ec6d1f5cefe6/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
|
gonum.org/v1/netlib v0.0.0-20181029234149-ec6d1f5cefe6/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
|
||||||
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
|
gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
|
||||||
gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
|
gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
|
||||||
|
gonum.org/v1/plot v0.17.0 h1:d0DwPVBe9jnEGqQBoZGl/P2M9WciJbG2CnV59C9QBT4=
|
||||||
|
gonum.org/v1/plot v0.17.0/go.mod h1:ipt2GUN1oqzr2O7wCjLDtw1ShfIYYNBp4o0O1Ez5B3Y=
|
||||||
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
|
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
|
||||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||||
@ -1267,6 +1285,7 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
|
|||||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||||
|
honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las=
|
||||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||||
|
|||||||
93
backend/scripts/loadgen/README.md
Normal file
93
backend/scripts/loadgen/README.md
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
# 后端服务压测工具
|
||||||
|
|
||||||
|
为部署在阿里云单机(4G/2C)的 TopFans 后端微服务设计。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/scripts/loadgen/
|
||||||
|
├── seed/ # 数据准备工具(CLI)
|
||||||
|
│ ├── main.go # seed CLI 入口
|
||||||
|
│ ├── stars.go users.go profiles.go assets.go
|
||||||
|
│ ├── slots_and_exhibits.go friendships.go
|
||||||
|
│ ├── tokens.go sequences.go cleanup.go
|
||||||
|
│ ├── seed_test.go # 单元测试
|
||||||
|
│ └── README.md
|
||||||
|
├── loadgen/ # 压测主程序
|
||||||
|
│ ├── main.go # loadgen CLI 入口
|
||||||
|
│ ├── preflight.go verify.go # 7 项开压前检查 + 压后验证
|
||||||
|
│ ├── lib/ # 核心库(16 个测试全过)
|
||||||
|
│ │ ├── csv.go client.go hdr.go log.go ramp.go
|
||||||
|
│ │ ├── circuit.go ssh_metrics.go config.go
|
||||||
|
│ │ └── *_test.go
|
||||||
|
│ ├── scenarios/ # 7 个场景(已注册)
|
||||||
|
│ │ ├── s1_login.go s2_read.go s3_like.go s4_mint.go
|
||||||
|
│ │ ├── s5_dashboard.go s6_ranking.go s7_place.go
|
||||||
|
│ │ ├── common.go scenarios.go
|
||||||
|
│ │ └── scenarios_test.go
|
||||||
|
│ └── reporter/ # 报告生成
|
||||||
|
│ ├── json.go csv.go plot.go markdown.go
|
||||||
|
├── monitor/ # 监控栈
|
||||||
|
│ ├── sample.sh # 后台采样(写到 metrics-feed.jsonl)
|
||||||
|
│ ├── docker-compose.monitor.yml
|
||||||
|
│ ├── prometheus.yml
|
||||||
|
│ └── grafana-dashboards/ # 4 个预置面板
|
||||||
|
├── recover/ # 一键灭火 + 备份还原
|
||||||
|
│ ├── emergency-stop.sh
|
||||||
|
│ └── restore-from-backup.sh
|
||||||
|
├── scripts/ # 部署到 prod
|
||||||
|
│ └── mint_reset.sh
|
||||||
|
└── reports/ # 跑测产出(gitignore)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 编译
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o bin/seed ./scripts/loadgen/seed/
|
||||||
|
GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o bin/loadgen ./scripts/loadgen/loadgen/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go test ./scripts/loadgen/...
|
||||||
|
```
|
||||||
|
|
||||||
|
**当前测试状态** (截至 Phase 7 完结):
|
||||||
|
- `seed` 包: 5/5 PASS
|
||||||
|
- `loadgen/lib` 包: 16/16 PASS
|
||||||
|
- `loadgen/scenarios` 包: 2/2 PASS
|
||||||
|
- 共 23 个测试全过
|
||||||
|
|
||||||
|
## 关键特性
|
||||||
|
|
||||||
|
### 1. 6 维红线判停(自动熔断)
|
||||||
|
|
||||||
|
| # | 红线 | 阈值 | 数据源 |
|
||||||
|
|---|------|------|--------|
|
||||||
|
| R1 | 客户端错误率 | > 5% 持续 30s | loadgen HDR |
|
||||||
|
| R2 | 客户端 P99 | > 3000ms 持续 30s | loadgen HDR |
|
||||||
|
| R3 | 5xx 比例 | > 10% 持续 10s | loadgen status |
|
||||||
|
| R4 | PG 连接数 | > 42 持续 30s | metrics-feed |
|
||||||
|
| R5 | 磁盘空闲 | < 5GB 持续 30s | metrics-feed |
|
||||||
|
| R6 | OOM 事件 | 瞬时触发 | metrics-feed |
|
||||||
|
|
||||||
|
### 2. CLAUDE.md 序列重置
|
||||||
|
|
||||||
|
seed 工具自动同步所有相关表的 PG 序列(避免后续 GORM 插入报 duplicate key)。
|
||||||
|
|
||||||
|
### 3. 数据隔离
|
||||||
|
|
||||||
|
所有测试数据用 `star_id = 999900` 物理隔离,不影响真实业务 star_id (87, 88, 91, 93, 94, 95)。
|
||||||
|
|
||||||
|
### 4. 凌晨窗口
|
||||||
|
|
||||||
|
执行窗口:凌晨 02:00-06:00 业务低峰。emergency-stop 一键回滚,restore-from-backup.sh 5-8min 还原。
|
||||||
|
|
||||||
|
## 详细文档
|
||||||
|
|
||||||
|
- **设计文档**: `docs/superpowers/specs/2026-06-12-load-testing-design.md`
|
||||||
|
- **实施计划**: `docs/superpowers/plans/2026-06-12-load-testing.md`
|
||||||
|
- **seed 工具说明**: `seed/README.md`
|
||||||
132
backend/scripts/loadgen/loadgen/lib/circuit.go
Normal file
132
backend/scripts/loadgen/loadgen/lib/circuit.go
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CircuitState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
CircuitOK CircuitState = iota
|
||||||
|
CircuitTripped
|
||||||
|
)
|
||||||
|
|
||||||
|
type CircuitBreaker struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
state CircuitState
|
||||||
|
|
||||||
|
errRateStart time.Time
|
||||||
|
p99Start time.Time
|
||||||
|
fiveXXStart time.Time
|
||||||
|
pgConnStart time.Time
|
||||||
|
diskStart time.Time
|
||||||
|
oomSeen bool
|
||||||
|
|
||||||
|
ErrRate float64
|
||||||
|
P99ThresholdMs int64
|
||||||
|
FiveXXRate float64
|
||||||
|
PGConnMax int
|
||||||
|
DiskMinGB int
|
||||||
|
SustainTime time.Duration
|
||||||
|
Sustain5xx time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCircuitBreaker() *CircuitBreaker {
|
||||||
|
return &CircuitBreaker{
|
||||||
|
ErrRate: 0.05,
|
||||||
|
P99ThresholdMs: 3000,
|
||||||
|
FiveXXRate: 0.10,
|
||||||
|
PGConnMax: 42,
|
||||||
|
DiskMinGB: 5,
|
||||||
|
SustainTime: 30 * time.Second,
|
||||||
|
Sustain5xx: 10 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientMetrics struct {
|
||||||
|
ErrorRate float64
|
||||||
|
P99Ms int64
|
||||||
|
FiveXXRate float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerMetrics struct {
|
||||||
|
PGActive int
|
||||||
|
DiskGB int
|
||||||
|
OOMEvent bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cb *CircuitBreaker) Check(client ClientMetrics, server ServerMetrics, now time.Time) bool {
|
||||||
|
cb.mu.Lock()
|
||||||
|
defer cb.mu.Unlock()
|
||||||
|
|
||||||
|
if client.ErrorRate > cb.ErrRate {
|
||||||
|
if cb.errRateStart.IsZero() {
|
||||||
|
cb.errRateStart = now
|
||||||
|
}
|
||||||
|
if now.Sub(cb.errRateStart) >= cb.SustainTime {
|
||||||
|
cb.state = CircuitTripped
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cb.errRateStart = time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if client.P99Ms > cb.P99ThresholdMs {
|
||||||
|
if cb.p99Start.IsZero() {
|
||||||
|
cb.p99Start = now
|
||||||
|
}
|
||||||
|
if now.Sub(cb.p99Start) >= cb.SustainTime {
|
||||||
|
cb.state = CircuitTripped
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cb.p99Start = time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if client.FiveXXRate > cb.FiveXXRate {
|
||||||
|
if cb.fiveXXStart.IsZero() {
|
||||||
|
cb.fiveXXStart = now
|
||||||
|
}
|
||||||
|
if now.Sub(cb.fiveXXStart) >= cb.Sustain5xx {
|
||||||
|
cb.state = CircuitTripped
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cb.fiveXXStart = time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if server.PGActive > cb.PGConnMax {
|
||||||
|
if cb.pgConnStart.IsZero() {
|
||||||
|
cb.pgConnStart = now
|
||||||
|
}
|
||||||
|
if now.Sub(cb.pgConnStart) >= cb.SustainTime {
|
||||||
|
cb.state = CircuitTripped
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cb.pgConnStart = time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if server.DiskGB < cb.DiskMinGB {
|
||||||
|
if cb.diskStart.IsZero() {
|
||||||
|
cb.diskStart = now
|
||||||
|
}
|
||||||
|
if now.Sub(cb.diskStart) >= cb.SustainTime {
|
||||||
|
cb.state = CircuitTripped
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cb.diskStart = time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if server.OOMEvent || cb.oomSeen {
|
||||||
|
cb.oomSeen = true
|
||||||
|
cb.state = CircuitTripped
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cb *CircuitBreaker) State() CircuitState { return cb.state }
|
||||||
63
backend/scripts/loadgen/loadgen/lib/circuit_test.go
Normal file
63
backend/scripts/loadgen/loadgen/lib/circuit_test.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCircuitBreaker_R1(t *testing.T) {
|
||||||
|
cb := NewCircuitBreaker()
|
||||||
|
now := time.Now()
|
||||||
|
if cb.Check(ClientMetrics{ErrorRate: 0.06}, ServerMetrics{}, now) {
|
||||||
|
t.Error("R1 should not trip on first check")
|
||||||
|
}
|
||||||
|
if !cb.Check(ClientMetrics{ErrorRate: 0.06}, ServerMetrics{}, now.Add(31*time.Second)) {
|
||||||
|
t.Error("R1 should trip after 30s sustain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCircuitBreaker_R2(t *testing.T) {
|
||||||
|
cb := NewCircuitBreaker()
|
||||||
|
now := time.Now()
|
||||||
|
cb.Check(ClientMetrics{P99Ms: 4000}, ServerMetrics{}, now)
|
||||||
|
if !cb.Check(ClientMetrics{P99Ms: 4000}, ServerMetrics{}, now.Add(31*time.Second)) {
|
||||||
|
t.Error("R2 P99>3000 sustained should trip")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCircuitBreaker_R4_PGConn(t *testing.T) {
|
||||||
|
cb := NewCircuitBreaker()
|
||||||
|
now := time.Now()
|
||||||
|
cb.Check(ClientMetrics{}, ServerMetrics{PGActive: 50}, now)
|
||||||
|
if !cb.Check(ClientMetrics{}, ServerMetrics{PGActive: 50}, now.Add(31*time.Second)) {
|
||||||
|
t.Error("R4 PG active > 42 sustained should trip")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCircuitBreaker_R5_Disk(t *testing.T) {
|
||||||
|
cb := NewCircuitBreaker()
|
||||||
|
now := time.Now()
|
||||||
|
cb.Check(ClientMetrics{}, ServerMetrics{DiskGB: 3}, now)
|
||||||
|
if !cb.Check(ClientMetrics{}, ServerMetrics{DiskGB: 3}, now.Add(31*time.Second)) {
|
||||||
|
t.Error("R5 disk < 5GB sustained should trip")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCircuitBreaker_R6_OOM_Instant(t *testing.T) {
|
||||||
|
cb := NewCircuitBreaker()
|
||||||
|
if !cb.Check(ClientMetrics{}, ServerMetrics{OOMEvent: true}, time.Now()) {
|
||||||
|
t.Error("R6 OOM should trip instantly without sustain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCircuitBreaker_Recovers(t *testing.T) {
|
||||||
|
cb := NewCircuitBreaker()
|
||||||
|
now := time.Now()
|
||||||
|
cb.Check(ClientMetrics{ErrorRate: 0.06}, ServerMetrics{}, now)
|
||||||
|
if cb.Check(ClientMetrics{ErrorRate: 0.01}, ServerMetrics{}, now.Add(10*time.Second)) {
|
||||||
|
t.Error("should not trip when error drops")
|
||||||
|
}
|
||||||
|
if cb.State() != CircuitOK {
|
||||||
|
t.Error("should remain OK")
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/scripts/loadgen/loadgen/lib/client.go
Normal file
28
backend/scripts/loadgen/loadgen/lib/client.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewHTTPClient(target string) *http.Client {
|
||||||
|
dialer := &net.Dialer{
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}
|
||||||
|
transport := &http.Transport{
|
||||||
|
DialContext: dialer.DialContext,
|
||||||
|
MaxConnsPerHost: 500,
|
||||||
|
MaxIdleConns: 500,
|
||||||
|
MaxIdleConnsPerHost: 200,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 5 * time.Second,
|
||||||
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
ResponseHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
return &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/scripts/loadgen/loadgen/lib/config.go
Normal file
18
backend/scripts/loadgen/loadgen/lib/config.go
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
const DefaultLoadtestPlaceholderURL = "<OSS_URL>/loadtest-placeholder.png"
|
||||||
|
|
||||||
|
func LoadtestPlaceholderURL() string {
|
||||||
|
if v := os.Getenv("LOADTEST_PLACEHOLDER_URL"); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return DefaultLoadtestPlaceholderURL
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
LoadtestStarID = int64(999900)
|
||||||
|
LoadtestUserMin = int64(30000001)
|
||||||
|
LoadtestUserMax = int64(30001000)
|
||||||
|
)
|
||||||
95
backend/scripts/loadgen/loadgen/lib/csv.go
Normal file
95
backend/scripts/loadgen/loadgen/lib/csv.go
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TestUser struct {
|
||||||
|
Phone string
|
||||||
|
Password string
|
||||||
|
UserID int64
|
||||||
|
StarID int64
|
||||||
|
JWTToken string
|
||||||
|
AssetIDs []int64
|
||||||
|
ExhibitionIDs []int64
|
||||||
|
SlotID3 int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadUsers(path string) ([]TestUser, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
r := csv.NewReader(f)
|
||||||
|
r.FieldsPerRecord = -1
|
||||||
|
|
||||||
|
header, err := r.Read()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(header) < 5 || header[0] != "phone" {
|
||||||
|
return nil, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSlotID3 := false
|
||||||
|
for i, h := range header {
|
||||||
|
if h == "slot_id_3" {
|
||||||
|
hasSlotID3 = true
|
||||||
|
_ = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var users []TestUser
|
||||||
|
for {
|
||||||
|
row, err := r.Read()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(row) < 7 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
uid, _ := strconv.ParseInt(row[2], 10, 64)
|
||||||
|
sid, _ := strconv.ParseInt(row[3], 10, 64)
|
||||||
|
u := TestUser{
|
||||||
|
Phone: row[0],
|
||||||
|
Password: row[1],
|
||||||
|
UserID: uid,
|
||||||
|
StarID: sid,
|
||||||
|
JWTToken: row[4],
|
||||||
|
AssetIDs: parseIDs(row[5]),
|
||||||
|
ExhibitionIDs: parseIDs(row[6]),
|
||||||
|
}
|
||||||
|
if hasSlotID3 && len(row) >= 8 {
|
||||||
|
u.SlotID3, _ = strconv.ParseInt(row[7], 10, 64)
|
||||||
|
}
|
||||||
|
users = append(users, u)
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseIDs(s string) []int64 {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ";")
|
||||||
|
out := make([]int64, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
v, err := strconv.ParseInt(p, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
81
backend/scripts/loadgen/loadgen/lib/csv_test.go
Normal file
81
backend/scripts/loadgen/loadgen/lib/csv_test.go
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadUsers(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "users.csv")
|
||||||
|
content := `phone,password,user_id,star_id,jwt_token,asset_ids,exhibition_ids
|
||||||
|
19900000001,Test@123,30000001,999900,token1,1000;1001;1002,5000;5001
|
||||||
|
19900000002,Test@123,30000002,999900,token2,1003,5002
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := LoadUsers(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(users) != 2 {
|
||||||
|
t.Fatalf("want 2 users, got %d", len(users))
|
||||||
|
}
|
||||||
|
|
||||||
|
u := users[0]
|
||||||
|
if u.UserID != 30000001 {
|
||||||
|
t.Errorf("uid=%d", u.UserID)
|
||||||
|
}
|
||||||
|
if len(u.AssetIDs) != 3 {
|
||||||
|
t.Errorf("assets=%v", u.AssetIDs)
|
||||||
|
}
|
||||||
|
if u.AssetIDs[0] != 1000 {
|
||||||
|
t.Errorf("first asset=%d", u.AssetIDs[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadUsersWithSlotID3(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "users.csv")
|
||||||
|
content := `phone,password,user_id,star_id,jwt_token,asset_ids,exhibition_ids,slot_id_3
|
||||||
|
19900000001,Test@123,30000001,999900,token1,1000,5000,42
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := LoadUsers(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if users[0].SlotID3 != 42 {
|
||||||
|
t.Errorf("slot_id_3=%d", users[0].SlotID3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseIDs(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want []int64
|
||||||
|
}{
|
||||||
|
{"", nil},
|
||||||
|
{"42", []int64{42}},
|
||||||
|
{"1;2;3", []int64{1, 2, 3}},
|
||||||
|
{"abc;5", []int64{5}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := parseIDs(c.in)
|
||||||
|
if len(got) != len(c.want) {
|
||||||
|
t.Errorf("parseIDs(%q) len=%d want %d", c.in, len(got), len(c.want))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != c.want[i] {
|
||||||
|
t.Errorf("parseIDs(%q)[%d]=%d want %d", c.in, i, got[i], c.want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/scripts/loadgen/loadgen/lib/hdr.go
Normal file
33
backend/scripts/loadgen/loadgen/lib/hdr.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/HdrHistogram/hdrhistogram-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LatencyRecorder struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
h *hdrhistogram.Histogram
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLatencyRecorder() *LatencyRecorder {
|
||||||
|
return &LatencyRecorder{
|
||||||
|
h: hdrhistogram.New(1, 30_000_000, 3),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LatencyRecorder) Record(latencyUs int64) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if latencyUs < 1 {
|
||||||
|
latencyUs = 1
|
||||||
|
}
|
||||||
|
_ = r.h.RecordValue(latencyUs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LatencyRecorder) Snapshot() *hdrhistogram.Histogram {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return hdrhistogram.Import(r.h.Export())
|
||||||
|
}
|
||||||
25
backend/scripts/loadgen/loadgen/lib/hdr_test.go
Normal file
25
backend/scripts/loadgen/loadgen/lib/hdr_test.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestLatencyRecorder(t *testing.T) {
|
||||||
|
r := NewLatencyRecorder()
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
r.Record(int64(10_000 + i*100))
|
||||||
|
}
|
||||||
|
snap := r.Snapshot()
|
||||||
|
p50 := snap.ValueAtQuantile(50.0)
|
||||||
|
if p50 < 10_000 || p50 > 20_000 {
|
||||||
|
t.Errorf("p50 out of range: %d", p50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLatencyRecorderClamp(t *testing.T) {
|
||||||
|
r := NewLatencyRecorder()
|
||||||
|
r.Record(0) // should clamp to 1
|
||||||
|
r.Record(-5)
|
||||||
|
snap := r.Snapshot()
|
||||||
|
if snap.TotalCount() != 2 {
|
||||||
|
t.Errorf("expected 2 records, got %d", snap.TotalCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
47
backend/scripts/loadgen/loadgen/lib/log.go
Normal file
47
backend/scripts/loadgen/loadgen/lib/log.go
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Dashboard struct {
|
||||||
|
scenario string
|
||||||
|
out io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDashboard(scenario string) *Dashboard {
|
||||||
|
return &Dashboard{scenario: scenario, out: os.Stderr}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) PerSecondLine(t time.Time, targetRPS, actualRPS float64, p50, p95, p99 int64, errRate float64, vu int) {
|
||||||
|
fmt.Fprintf(d.out, "[%s] %-12s | target=%5.0f actual=%6.1f | p50=%4d p95=%4d p99=%5d | err=%.1f%% | vu=%d\n",
|
||||||
|
t.Format("15:04:05"), d.scenario, targetRPS, actualRPS,
|
||||||
|
p50/1000, p95/1000, p99/1000, errRate*100, vu)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Quantiler interface {
|
||||||
|
ValueAtQuantile(float64) int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dashboard) PerMinuteSummary(elapsed time.Duration, totalReqs, errs int64, h Quantiler, status2xx, status4xx, status5xx int64) {
|
||||||
|
p50 := h.ValueAtQuantile(50) / 1000
|
||||||
|
p95 := h.ValueAtQuantile(95) / 1000
|
||||||
|
p99 := h.ValueAtQuantile(99) / 1000
|
||||||
|
fmt.Fprintf(d.out, "═══════════════════════════════════════════════════════════════\n")
|
||||||
|
fmt.Fprintf(d.out, " Requests: %d (%.1f/s avg)\n", totalReqs, float64(totalReqs)/elapsed.Seconds())
|
||||||
|
fmt.Fprintf(d.out, " Errors: %d (%.1f%%)\n", errs, percent(errs, totalReqs))
|
||||||
|
fmt.Fprintf(d.out, " Latency p50: %dms p95: %dms p99: %dms\n", p50, p95, p99)
|
||||||
|
fmt.Fprintf(d.out, " Status: 2xx=%.1f%% 4xx=%.1f%% 5xx=%.1f%%\n",
|
||||||
|
percent(status2xx, totalReqs), percent(status4xx, totalReqs), percent(status5xx, totalReqs))
|
||||||
|
fmt.Fprintf(d.out, "═══════════════════════════════════════════════════════════════\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func percent(part, total int64) float64 {
|
||||||
|
if total == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(part) / float64(total) * 100
|
||||||
|
}
|
||||||
41
backend/scripts/loadgen/loadgen/lib/ramp.go
Normal file
41
backend/scripts/loadgen/loadgen/lib/ramp.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Stage struct {
|
||||||
|
RPS int
|
||||||
|
Duration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type StageScheduler struct {
|
||||||
|
stages []Stage
|
||||||
|
idx int
|
||||||
|
start time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStageScheduler(stages []Stage) *StageScheduler {
|
||||||
|
return &StageScheduler{stages: stages, start: time.Now()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StageScheduler) CurrentRPS() int {
|
||||||
|
if s.idx >= len(s.stages) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return s.stages[s.idx].RPS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StageScheduler) Advance() bool {
|
||||||
|
if s.idx >= len(s.stages) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
elapsed := time.Since(s.start)
|
||||||
|
if elapsed >= s.stages[s.idx].Duration {
|
||||||
|
s.idx++
|
||||||
|
s.start = time.Now()
|
||||||
|
return s.idx < len(s.stages)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *StageScheduler) StageIndex() int { return s.idx }
|
||||||
|
func (s *StageScheduler) StageCount() int { return len(s.stages) }
|
||||||
40
backend/scripts/loadgen/loadgen/lib/ramp_test.go
Normal file
40
backend/scripts/loadgen/loadgen/lib/ramp_test.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStageScheduler(t *testing.T) {
|
||||||
|
s := NewStageScheduler([]Stage{
|
||||||
|
{RPS: 10, Duration: 50 * time.Millisecond},
|
||||||
|
{RPS: 20, Duration: 50 * time.Millisecond},
|
||||||
|
})
|
||||||
|
if got := s.CurrentRPS(); got != 10 {
|
||||||
|
t.Errorf("first rps=%d", got)
|
||||||
|
}
|
||||||
|
time.Sleep(60 * time.Millisecond)
|
||||||
|
if !s.Advance() {
|
||||||
|
t.Fatal("should still have more stages")
|
||||||
|
}
|
||||||
|
if got := s.CurrentRPS(); got != 20 {
|
||||||
|
t.Errorf("second rps=%d", got)
|
||||||
|
}
|
||||||
|
time.Sleep(60 * time.Millisecond)
|
||||||
|
if s.Advance() {
|
||||||
|
t.Fatal("should be done")
|
||||||
|
}
|
||||||
|
if got := s.CurrentRPS(); got != 0 {
|
||||||
|
t.Errorf("after end rps=%d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStageSchedulerEmpty(t *testing.T) {
|
||||||
|
s := NewStageScheduler(nil)
|
||||||
|
if s.CurrentRPS() != 0 {
|
||||||
|
t.Error("empty should return 0 RPS")
|
||||||
|
}
|
||||||
|
if s.Advance() {
|
||||||
|
t.Error("empty should not advance")
|
||||||
|
}
|
||||||
|
}
|
||||||
70
backend/scripts/loadgen/loadgen/lib/ssh_metrics.go
Normal file
70
backend/scripts/loadgen/loadgen/lib/ssh_metrics.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MetricsLine struct {
|
||||||
|
Timestamp string
|
||||||
|
Fields map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TailMetricsFeed(sshTarget, path string) (<-chan MetricsLine, error) {
|
||||||
|
out := make(chan MetricsLine, 100)
|
||||||
|
cmd := exec.Command("ssh", sshTarget, "tail -F "+path)
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(out)
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
ml := parseMetricsLine(line)
|
||||||
|
select {
|
||||||
|
case out <- ml:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMetricsLine(s string) MetricsLine {
|
||||||
|
parts := strings.SplitN(s, " ", 2)
|
||||||
|
ml := MetricsLine{Fields: make(map[string]string)}
|
||||||
|
if len(parts) >= 1 {
|
||||||
|
ml.Timestamp = parts[0]
|
||||||
|
}
|
||||||
|
if len(parts) == 2 {
|
||||||
|
for _, kv := range strings.Fields(parts[1]) {
|
||||||
|
eq := strings.SplitN(kv, "=", 2)
|
||||||
|
if len(eq) == 2 {
|
||||||
|
ml.Fields[eq[0]] = eq[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ml
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ml MetricsLine) ToServerMetrics() ServerMetrics {
|
||||||
|
sm := ServerMetrics{}
|
||||||
|
if v, ok := ml.Fields["pg_active"]; ok {
|
||||||
|
sm.PGActive, _ = strconv.Atoi(v)
|
||||||
|
}
|
||||||
|
if v, ok := ml.Fields["disk_free"]; ok {
|
||||||
|
sm.DiskGB, _ = strconv.Atoi(v)
|
||||||
|
}
|
||||||
|
if v, ok := ml.Fields["oom"]; ok && v == "true" {
|
||||||
|
sm.OOMEvent = true
|
||||||
|
}
|
||||||
|
return sm
|
||||||
|
}
|
||||||
38
backend/scripts/loadgen/loadgen/lib/ssh_metrics_test.go
Normal file
38
backend/scripts/loadgen/loadgen/lib/ssh_metrics_test.go
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package lib
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseMetricsLine(t *testing.T) {
|
||||||
|
ml := parseMetricsLine("1700000000 pg_active=42 disk_free=12 oom=false")
|
||||||
|
if ml.Timestamp != "1700000000" {
|
||||||
|
t.Errorf("ts=%q", ml.Timestamp)
|
||||||
|
}
|
||||||
|
if ml.Fields["pg_active"] != "42" {
|
||||||
|
t.Errorf("pg=%q", ml.Fields["pg_active"])
|
||||||
|
}
|
||||||
|
if ml.Fields["disk_free"] != "12" {
|
||||||
|
t.Errorf("disk=%q", ml.Fields["disk_free"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToServerMetrics(t *testing.T) {
|
||||||
|
ml := parseMetricsLine("1700000000 pg_active=50 disk_free=3 oom=true")
|
||||||
|
sm := ml.ToServerMetrics()
|
||||||
|
if sm.PGActive != 50 {
|
||||||
|
t.Errorf("pg=%d", sm.PGActive)
|
||||||
|
}
|
||||||
|
if sm.DiskGB != 3 {
|
||||||
|
t.Errorf("disk=%d", sm.DiskGB)
|
||||||
|
}
|
||||||
|
if !sm.OOMEvent {
|
||||||
|
t.Error("oom should be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToServerMetricsEmpty(t *testing.T) {
|
||||||
|
ml := parseMetricsLine("1700000000")
|
||||||
|
sm := ml.ToServerMetrics()
|
||||||
|
if sm.PGActive != 0 || sm.DiskGB != 0 || sm.OOMEvent {
|
||||||
|
t.Errorf("empty should be zero: %+v", sm)
|
||||||
|
}
|
||||||
|
}
|
||||||
228
backend/scripts/loadgen/loadgen/main.go
Normal file
228
backend/scripts/loadgen/loadgen/main.go
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/scripts/loadgen/loadgen/lib"
|
||||||
|
"github.com/topfans/backend/scripts/loadgen/loadgen/reporter"
|
||||||
|
"github.com/topfans/backend/scripts/loadgen/loadgen/scenarios"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
scenariosFlag = flag.String("scenarios", "", "comma-separated, e.g. S1,S2,S3")
|
||||||
|
stage = flag.String("stage", "step", "baseline|step|soak|stress")
|
||||||
|
stepSchedule = flag.String("step-schedule", "", "comma-separated RPS list for --stage=step, e.g. '2,5,10,15,25,40' for S1")
|
||||||
|
rps = flag.Int("rps", 0, "single-RPS mode (overrides stage)")
|
||||||
|
vus = flag.Int("vus", 0, "max concurrent virtual users (default: auto)")
|
||||||
|
duration = flag.Duration("duration", 0, "single stage duration (default per §5.3)")
|
||||||
|
interPause = flag.Duration("inter-scenario-pause", 15*time.Minute, "pause between scenarios")
|
||||||
|
monitor = flag.String("monitor", "lite", "off|lite|full")
|
||||||
|
prodSSH = flag.String("prod-ssh", "", "user@host for ssh metrics")
|
||||||
|
target = flag.String("target", "http://101.132.250.62:8080", "target gateway URL")
|
||||||
|
cmd = flag.String("cmd", "run", "run|preflight|verify|report")
|
||||||
|
inputDir = flag.String("input", "", "for cmd=report: input dir")
|
||||||
|
outputFile = flag.String("output", "./report.md", "for cmd=report: output file")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
|
||||||
|
log.Printf("loadgen starting: cmd=%s scenarios=%s", *cmd, *scenariosFlag)
|
||||||
|
|
||||||
|
switch *cmd {
|
||||||
|
case "run":
|
||||||
|
if err := runLoadgen(*target, *scenariosFlag, *stage, *stepSchedule, *rps, *vus, *duration, *interPause, *monitor, *prodSSH); err != nil {
|
||||||
|
log.Fatalf("run failed: %v", err)
|
||||||
|
}
|
||||||
|
case "preflight":
|
||||||
|
if err := runPreflight(*target, *prodSSH); err != nil {
|
||||||
|
log.Fatalf("preflight: %v", err)
|
||||||
|
}
|
||||||
|
case "verify":
|
||||||
|
if err := runVerify(*prodSSH); err != nil {
|
||||||
|
log.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
case "report":
|
||||||
|
if err := runReport(*inputDir, *outputFile); err != nil {
|
||||||
|
log.Fatalf("report: %v", err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
log.Fatalf("unknown cmd: %s", *cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runLoadgen(target, scenarioIDs, stage, stepSchedule string, rps, vus int, duration, interPause time.Duration, monitorMode, prodSSH string) error {
|
||||||
|
users, err := lib.LoadUsers("users.csv")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load users.csv: %w (先跑 `seed` 生成 users.csv)", err)
|
||||||
|
}
|
||||||
|
log.Printf("loaded %d users", len(users))
|
||||||
|
|
||||||
|
client := lib.NewHTTPClient(target)
|
||||||
|
recorder := lib.NewLatencyRecorder()
|
||||||
|
breaker := lib.NewCircuitBreaker()
|
||||||
|
dashboard := lib.NewDashboard(scenarioIDs)
|
||||||
|
|
||||||
|
var errCount, totalCount, fiveXXCount atomic.Int64
|
||||||
|
|
||||||
|
var stages []int
|
||||||
|
if stage == "step" {
|
||||||
|
if stepSchedule == "" {
|
||||||
|
return fmt.Errorf("--step-schedule required for --stage=step (e.g. --step-schedule='2,5,10,15,25,40')")
|
||||||
|
}
|
||||||
|
for _, s := range strings.Split(stepSchedule, ",") {
|
||||||
|
var v int
|
||||||
|
fmt.Sscanf(strings.TrimSpace(s), "%d", &v)
|
||||||
|
if v > 0 {
|
||||||
|
stages = append(stages, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(stages) == 0 {
|
||||||
|
return fmt.Errorf("--step-schedule invalid: %q", stepSchedule)
|
||||||
|
}
|
||||||
|
log.Printf("step schedule: %v", stages)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// SIGINT handler
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
sig := <-sigCh
|
||||||
|
log.Printf("signal %v received, shutting down gracefully...", sig)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// server metrics consumer (R4/R5/R6)
|
||||||
|
if monitorMode != "off" && prodSSH != "" {
|
||||||
|
feed, err := lib.TailMetricsFeed(prodSSH, "/opt/topfans/loadtest/metrics-feed.jsonl")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠️ tail metrics feed failed: %v (R4/R5/R6 will not be checked)", err)
|
||||||
|
} else {
|
||||||
|
go consumeServerMetrics(ctx, feed, breaker, recorder, &errCount, &totalCount, &fiveXXCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := strings.Split(scenarioIDs, ",")
|
||||||
|
for idx, id := range ids {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("=== scenario %d/%d: %s ===", idx+1, len(ids), id)
|
||||||
|
s, err := scenarios.Get(id, client, users, &errCount, &totalCount, &fiveXXCount, recorder, breaker, prodSSH)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("scenario %s: %w", id, err)
|
||||||
|
}
|
||||||
|
if err := s.Run(ctx, rps, duration, dashboard, breaker, stages); err != nil {
|
||||||
|
return fmt.Errorf("run scenario %s: %w", id, err)
|
||||||
|
}
|
||||||
|
if breaker.State() == lib.CircuitTripped {
|
||||||
|
log.Printf("⚠️ circuit tripped, stopping")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if idx < len(ids)-1 {
|
||||||
|
log.Printf("inter-scenario pause %v", interPause)
|
||||||
|
time.Sleep(interPause)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write final report
|
||||||
|
if err := reporter.WriteJSON("report.json", scenarioIDs, recorder.Snapshot(), totalCount.Load(), errCount.Load(), fiveXXCount.Load()); err != nil {
|
||||||
|
return fmt.Errorf("write report: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("✅ loadgen done. total=%d err=%d fiveXX=%d", totalCount.Load(), errCount.Load(), fiveXXCount.Load())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func consumeServerMetrics(ctx context.Context, feed <-chan lib.MetricsLine, breaker *lib.CircuitBreaker, rec *lib.LatencyRecorder, errCount, totalCount, fiveXXCount *atomic.Int64) {
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
var latestServer lib.ServerMetrics
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case ml := <-feed:
|
||||||
|
latestServer = ml.ToServerMetrics()
|
||||||
|
case <-ticker.C:
|
||||||
|
tot := totalCount.Load()
|
||||||
|
if tot == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
clientMetrics := lib.ClientMetrics{
|
||||||
|
ErrorRate: float64(errCount.Load()) / float64(tot),
|
||||||
|
FiveXXRate: float64(fiveXXCount.Load()) / float64(tot),
|
||||||
|
}
|
||||||
|
snap := rec.Snapshot()
|
||||||
|
if snap.TotalCount() > 0 {
|
||||||
|
clientMetrics.P99Ms = snap.ValueAtPercentile(99) / 1000
|
||||||
|
}
|
||||||
|
if breaker.Check(clientMetrics, latestServer, time.Now()) {
|
||||||
|
log.Printf("🚨 circuit breaker tripped!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runReport(inputDir, output string) error {
|
||||||
|
if inputDir == "" {
|
||||||
|
return fmt.Errorf("--input required for cmd=report")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 收集 reports/run-*/ 下的 *.json
|
||||||
|
var scenarioReports []reporter.RunReport
|
||||||
|
matches, _ := filepath.Glob(filepath.Join(inputDir, "**", "*.json"))
|
||||||
|
for _, m := range matches {
|
||||||
|
data, err := os.ReadFile(m)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rr reporter.RunReport
|
||||||
|
if err := json.Unmarshal(data, &rr); err != nil {
|
||||||
|
log.Printf("skip %s: %v", m, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scenarioReports = append(scenarioReports, rr)
|
||||||
|
}
|
||||||
|
if len(scenarioReports) == 0 {
|
||||||
|
return fmt.Errorf("no JSON reports found in %s", inputDir)
|
||||||
|
}
|
||||||
|
log.Printf("collected %d scenario reports", len(scenarioReports))
|
||||||
|
|
||||||
|
// 2. baseline.csv
|
||||||
|
baselinePath := filepath.Join(inputDir, "baseline.csv")
|
||||||
|
if err := reporter.WriteBaselineCSV(baselinePath, scenarioReports); err != nil {
|
||||||
|
return fmt.Errorf("write baseline: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("wrote %s", baselinePath)
|
||||||
|
|
||||||
|
// 3. 转 ScenarioReport (供 markdown 用)
|
||||||
|
scenarioMarkdownReports := make([]reporter.ScenarioReport, 0, len(scenarioReports))
|
||||||
|
for _, r := range scenarioReports {
|
||||||
|
scenarioMarkdownReports = append(scenarioMarkdownReports, reporter.ScenarioReport{
|
||||||
|
ID: r.Scenario,
|
||||||
|
KneeRPS: 0, // 拐点需要分析 raw data 算,简化版留 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. markdown
|
||||||
|
if err := reporter.GenerateMarkdown(output, scenarioMarkdownReports); err != nil {
|
||||||
|
return fmt.Errorf("write markdown: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("wrote %s", output)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
134
backend/scripts/loadgen/loadgen/preflight.go
Normal file
134
backend/scripts/loadgen/loadgen/preflight.go
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/scripts/loadgen/loadgen/lib"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CheckResult struct {
|
||||||
|
Name string
|
||||||
|
Passed bool
|
||||||
|
Detail string
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPreflight(target, prodSSH string) error {
|
||||||
|
checks := []CheckResult{}
|
||||||
|
|
||||||
|
// ① Gateway /health
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := client.Get(target + "/health")
|
||||||
|
checks = append(checks, CheckResult{
|
||||||
|
Name: "① Gateway /health",
|
||||||
|
Passed: err == nil && resp.StatusCode == 200,
|
||||||
|
Detail: fmt.Sprintf("status=%d err=%v", ifZero(resp.StatusCode), err),
|
||||||
|
})
|
||||||
|
if resp != nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② SSH to prod
|
||||||
|
if prodSSH != "" {
|
||||||
|
cmd := exec.Command("ssh", "-o", "ConnectTimeout=5", prodSSH, "echo connected")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
checks = append(checks, CheckResult{
|
||||||
|
Name: "② SSH to prod",
|
||||||
|
Passed: err == nil && strings.Contains(string(out), "connected"),
|
||||||
|
Detail: strings.TrimSpace(string(out)),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ③ pg_dump backup file exists
|
||||||
|
cmd = exec.Command("ssh", prodSSH, "ls -t /opt/topfans/backups/pre-loadtest-*.sql 2>/dev/null | head -1")
|
||||||
|
out, _ = cmd.Output()
|
||||||
|
backupFile := strings.TrimSpace(string(out))
|
||||||
|
info, statErr := os.Stat(backupFile)
|
||||||
|
sizeOK := statErr == nil && info.Size() > 50*1024*1024
|
||||||
|
checks = append(checks, CheckResult{
|
||||||
|
Name: "③ pg_dump backup exists (>50MB)",
|
||||||
|
Passed: sizeOK,
|
||||||
|
Detail: fmt.Sprintf("file=%s size=%d", backupFile, ifZero64(info.Size())),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ⑤ prod 磁盘空闲 > 10GB
|
||||||
|
cmd = exec.Command("ssh", prodSSH, "df -B1G /opt | tail -1 | awk '{print $4}'")
|
||||||
|
out, _ = cmd.Output()
|
||||||
|
var freeGB int
|
||||||
|
fmt.Sscanf(strings.TrimSpace(string(out)), "%d", &freeGB)
|
||||||
|
checks = append(checks, CheckResult{
|
||||||
|
Name: "⑤ prod 磁盘空闲 > 10GB",
|
||||||
|
Passed: freeGB > 10,
|
||||||
|
Detail: fmt.Sprintf("free=%dGB", freeGB),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ④ 阿里云快照(< 24h, 人工确认)
|
||||||
|
checks = append(checks, CheckResult{
|
||||||
|
Name: "④ 阿里云快照 < 24h (人工确认)",
|
||||||
|
Passed: true,
|
||||||
|
Detail: "需运维在 ECS 控制台确认",
|
||||||
|
})
|
||||||
|
|
||||||
|
// ⑥ users.csv 加载
|
||||||
|
if _, err := os.Stat("users.csv"); err == nil {
|
||||||
|
if users, err := lib.LoadUsers("users.csv"); err == nil {
|
||||||
|
checks = append(checks, CheckResult{
|
||||||
|
Name: "⑥ users.csv 1000 rows",
|
||||||
|
Passed: len(users) == 1000,
|
||||||
|
Detail: fmt.Sprintf("rows=%d", len(users)),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
checks = append(checks, CheckResult{Name: "⑥ users.csv", Passed: false, Detail: err.Error()})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
checks = append(checks, CheckResult{Name: "⑥ users.csv 存在", Passed: false, Detail: err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ⑦ JWT_SECRET set
|
||||||
|
checks = append(checks, CheckResult{
|
||||||
|
Name: "⑦ JWT_SECRET set",
|
||||||
|
Passed: len(os.Getenv("JWT_SECRET")) > 0,
|
||||||
|
Detail: "set" + ifEmpty(os.Getenv("JWT_SECRET") == "", "/empty"),
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, c := range checks {
|
||||||
|
mark := "✓"
|
||||||
|
if !c.Passed {
|
||||||
|
mark = "✗"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s %s — %s\n", mark, c.Name, c.Detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range checks {
|
||||||
|
if !c.Passed {
|
||||||
|
return fmt.Errorf("preflight failed: %s", c.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println("ALL CHECKS PASSED — 可以开压")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ifZero(v int) int {
|
||||||
|
if v == 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func ifZero64(v int64) int64 {
|
||||||
|
if v == 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func ifEmpty(empty bool, s string) string {
|
||||||
|
if empty {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
110
backend/scripts/loadgen/loadgen/reporter/json.go
Normal file
110
backend/scripts/loadgen/loadgen/reporter/json.go
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
package reporter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/HdrHistogram/hdrhistogram-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RunReport struct {
|
||||||
|
Scenario string `json:"scenario"`
|
||||||
|
TotalRequests int64 `json:"total_requests"`
|
||||||
|
Errors int64 `json:"errors"`
|
||||||
|
FiveXX int64 `json:"five_xx"`
|
||||||
|
P50Us int64 `json:"p50_us"`
|
||||||
|
P95Us int64 `json:"p95_us"`
|
||||||
|
P99Us int64 `json:"p99_us"`
|
||||||
|
MaxUs int64 `json:"max_us"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteJSON(path string, scenario string, h *hdrhistogram.Histogram, total, errs, fiveXX int64) error {
|
||||||
|
r := RunReport{
|
||||||
|
Scenario: scenario,
|
||||||
|
TotalRequests: total,
|
||||||
|
Errors: errs,
|
||||||
|
FiveXX: fiveXX,
|
||||||
|
P50Us: h.ValueAtPercentile(50),
|
||||||
|
P95Us: h.ValueAtPercentile(95),
|
||||||
|
P99Us: h.ValueAtPercentile(99),
|
||||||
|
MaxUs: h.Max(),
|
||||||
|
}
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
return json.NewEncoder(f).Encode(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteBaselineCSV(path string, scenarios []RunReport) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
if _, err := f.WriteString("scenario,total,errors,five_xx,p50_ms,p95_ms,p99_ms,max_ms\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, s := range scenarios {
|
||||||
|
_, err := f.WriteString(jsonLine(s) + "\n")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonLine(s RunReport) string {
|
||||||
|
b, _ := json.Marshal(s)
|
||||||
|
s2 := string(b)
|
||||||
|
if len(s2) >= 2 && s2[0] == '{' {
|
||||||
|
// strip braces for CSV-friendly format
|
||||||
|
return s.Scenario + "," + itoa(s.TotalRequests) + "," + itoa(s.Errors) + "," + itoa(s.FiveXX) + "," + ms(s.P50Us) + "," + ms(s.P95Us) + "," + ms(s.P99Us) + "," + ms(s.MaxUs)
|
||||||
|
}
|
||||||
|
return s2
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int64) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
neg := n < 0
|
||||||
|
if neg {
|
||||||
|
n = -n
|
||||||
|
}
|
||||||
|
for n > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + n%10)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
i--
|
||||||
|
buf[i] = '-'
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func ms(us int64) string {
|
||||||
|
// us / 1000 as float
|
||||||
|
return formatFloat(float64(us) / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatFloat(f float64) string {
|
||||||
|
// simple 2-decimal format
|
||||||
|
intPart := int64(f)
|
||||||
|
frac := int64((f - float64(intPart)) * 100)
|
||||||
|
if frac < 0 {
|
||||||
|
frac = -frac
|
||||||
|
}
|
||||||
|
return itoa(intPart) + "." + pad2(frac)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pad2(n int64) string {
|
||||||
|
if n < 10 {
|
||||||
|
return "0" + itoa(n)
|
||||||
|
}
|
||||||
|
return itoa(n)
|
||||||
|
}
|
||||||
44
backend/scripts/loadgen/loadgen/reporter/markdown.go
Normal file
44
backend/scripts/loadgen/loadgen/reporter/markdown.go
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
package reporter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ScenarioReport struct {
|
||||||
|
ID string
|
||||||
|
Stages []StageReport
|
||||||
|
KneeRPS int
|
||||||
|
TopBottleneck string
|
||||||
|
}
|
||||||
|
|
||||||
|
type StageReport struct {
|
||||||
|
RPS int
|
||||||
|
P50Ms float64
|
||||||
|
P95Ms float64
|
||||||
|
P99Ms float64
|
||||||
|
ErrorRate float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateMarkdown(path string, scenarios []ScenarioReport) error {
|
||||||
|
f, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
fmt.Fprintf(f, "# 压测报告\n\n")
|
||||||
|
for _, s := range scenarios {
|
||||||
|
fmt.Fprintf(f, "## %s\n\n", s.ID)
|
||||||
|
fmt.Fprintf(f, "**拐点 RPS**: %d\n\n", s.KneeRPS)
|
||||||
|
fmt.Fprintf(f, "**Top 瓶颈**: %s\n\n", s.TopBottleneck)
|
||||||
|
fmt.Fprintf(f, "| Stage | RPS | P50ms | P95ms | P99ms | Err%% |\n")
|
||||||
|
fmt.Fprintf(f, "|-------|-----|-------|-------|-------|------|\n")
|
||||||
|
for _, st := range s.Stages {
|
||||||
|
fmt.Fprintf(f, "| - | %d | %.1f | %.1f | %.1f | %.1f |\n",
|
||||||
|
st.RPS, st.P50Ms, st.P95Ms, st.P99Ms, st.ErrorRate*100)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(f, "\n")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
58
backend/scripts/loadgen/loadgen/reporter/plot.go
Normal file
58
backend/scripts/loadgen/loadgen/reporter/plot.go
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package reporter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gonum.org/v1/plot"
|
||||||
|
"gonum.org/v1/plot/plotter"
|
||||||
|
"gonum.org/v1/plot/vg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Sample struct {
|
||||||
|
RPS float64
|
||||||
|
P99Ms float64
|
||||||
|
ErrorRate float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func PlotRPSLatencyError(scenario string, samples []Sample, outPath string) error {
|
||||||
|
p := plot.New()
|
||||||
|
p.Title.Text = scenario + " — RPS / P99 / Error"
|
||||||
|
p.X.Label.Text = "Stage"
|
||||||
|
p.Y.Label.Text = "Value"
|
||||||
|
|
||||||
|
rpsPts := make(plotter.XYs, len(samples))
|
||||||
|
p99Pts := make(plotter.XYs, len(samples))
|
||||||
|
errPts := make(plotter.XYs, len(samples))
|
||||||
|
for i, s := range samples {
|
||||||
|
rpsPts[i].X = float64(i)
|
||||||
|
rpsPts[i].Y = s.RPS
|
||||||
|
p99Pts[i].X = float64(i)
|
||||||
|
p99Pts[i].Y = s.P99Ms
|
||||||
|
errPts[i].X = float64(i)
|
||||||
|
errPts[i].Y = s.ErrorRate * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
rpsLine, err := plotter.NewLine(rpsPts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p99Line, err := plotter.NewLine(p99Pts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
errLine, err := plotter.NewLine(errPts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.Add(rpsLine, p99Line, errLine)
|
||||||
|
p.Legend.Add("RPS", rpsLine)
|
||||||
|
p.Legend.Add("P99 ms", p99Line)
|
||||||
|
p.Legend.Add("Error %", errLine)
|
||||||
|
|
||||||
|
f, err := os.Create(outPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
return p.Save(12*vg.Inch, 6*vg.Inch, outPath)
|
||||||
|
}
|
||||||
52
backend/scripts/loadgen/loadgen/scenarios/common.go
Normal file
52
backend/scripts/loadgen/loadgen/scenarios/common.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package scenarios
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/scripts/loadgen/loadgen/lib"
|
||||||
|
)
|
||||||
|
|
||||||
|
const DefaultBaseURL = "http://101.132.250.62:8080"
|
||||||
|
|
||||||
|
func doRequest(client *http.Client, req *http.Request, rec *lib.LatencyRecorder, errCount, totalCount, fiveXXCount *atomic.Int64, breaker *lib.CircuitBreaker) {
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
latency := time.Since(start)
|
||||||
|
rec.Record(latency.Microseconds())
|
||||||
|
totalCount.Add(1)
|
||||||
|
if err != nil {
|
||||||
|
errCount.Add(1)
|
||||||
|
checkBreaker(client, rec, errCount, totalCount, fiveXXCount, breaker)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
switch {
|
||||||
|
case resp.StatusCode >= 500:
|
||||||
|
fiveXXCount.Add(1)
|
||||||
|
errCount.Add(1)
|
||||||
|
case resp.StatusCode >= 400:
|
||||||
|
errCount.Add(1)
|
||||||
|
}
|
||||||
|
checkBreaker(client, rec, errCount, totalCount, fiveXXCount, breaker)
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkBreaker(client *http.Client, rec *lib.LatencyRecorder, errCount, totalCount, fiveXXCount *atomic.Int64, breaker *lib.CircuitBreaker) {
|
||||||
|
if breaker == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tot := totalCount.Load()
|
||||||
|
if tot == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clientMetrics := lib.ClientMetrics{
|
||||||
|
ErrorRate: float64(errCount.Load()) / float64(tot),
|
||||||
|
FiveXXRate: float64(fiveXXCount.Load()) / float64(tot),
|
||||||
|
}
|
||||||
|
snap := rec.Snapshot()
|
||||||
|
if snap.TotalCount() > 0 {
|
||||||
|
clientMetrics.P99Ms = snap.ValueAtPercentile(99) / 1000
|
||||||
|
}
|
||||||
|
breaker.Check(clientMetrics, lib.ServerMetrics{}, time.Now())
|
||||||
|
}
|
||||||
25
backend/scripts/loadgen/loadgen/scenarios/helpers.go
Normal file
25
backend/scripts/loadgen/loadgen/scenarios/helpers.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package scenarios
|
||||||
|
|
||||||
|
import "log"
|
||||||
|
|
||||||
|
func logf(format string, args ...any) {
|
||||||
|
log.Printf(format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mintNameImpl(uid int64, round int) string {
|
||||||
|
return "loadtest_mint_30000001_round" + itoaInt(int64(round))
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoaInt(n int64) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
for n > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + n%10)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
62
backend/scripts/loadgen/loadgen/scenarios/s1_login.go
Normal file
62
backend/scripts/loadgen/loadgen/scenarios/s1_login.go
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
package scenarios
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/scripts/loadgen/loadgen/lib"
|
||||||
|
)
|
||||||
|
|
||||||
|
type s1Login struct {
|
||||||
|
client *http.Client
|
||||||
|
users []lib.TestUser
|
||||||
|
errCount *atomic.Int64
|
||||||
|
totalCount *atomic.Int64
|
||||||
|
fiveXXCount *atomic.Int64
|
||||||
|
rec *lib.LatencyRecorder
|
||||||
|
breaker *lib.CircuitBreaker
|
||||||
|
prodSSH string
|
||||||
|
baseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { register("S1", newS1) }
|
||||||
|
|
||||||
|
func newS1(c *http.Client, u []lib.TestUser, e, t, f *atomic.Int64, r *lib.LatencyRecorder, b *lib.CircuitBreaker, ssh string) Scenario {
|
||||||
|
return &s1Login{client: c, users: u, errCount: e, totalCount: t, fiveXXCount: f, rec: r, breaker: b, prodSSH: ssh, baseURL: DefaultBaseURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s1Login) Run(ctx context.Context, rpsOverride int, durationOverride time.Duration, dash *lib.Dashboard, breaker *lib.CircuitBreaker, stages []int) error {
|
||||||
|
targetRPS := rpsOverride
|
||||||
|
if targetRPS == 0 {
|
||||||
|
targetRPS = 15
|
||||||
|
}
|
||||||
|
duration := durationOverride
|
||||||
|
if duration == 0 {
|
||||||
|
duration = 2 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(time.Second / time.Duration(targetRPS))
|
||||||
|
defer ticker.Stop()
|
||||||
|
timeout := time.NewTimer(duration)
|
||||||
|
defer timeout.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-timeout.C:
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
u := s.users[rand.Intn(len(s.users))]
|
||||||
|
body, _ := json.Marshal(map[string]string{"mobile": u.Phone, "password": u.Password})
|
||||||
|
req, _ := http.NewRequest("POST", s.baseURL+"/api/v1/auth/login", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
doRequest(s.client, req, s.rec, s.errCount, s.totalCount, s.fiveXXCount, s.breaker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
backend/scripts/loadgen/loadgen/scenarios/s2_read.go
Normal file
63
backend/scripts/loadgen/loadgen/scenarios/s2_read.go
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
package scenarios
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/scripts/loadgen/loadgen/lib"
|
||||||
|
)
|
||||||
|
|
||||||
|
type s2Read struct {
|
||||||
|
client *http.Client
|
||||||
|
users []lib.TestUser
|
||||||
|
errCount *atomic.Int64
|
||||||
|
totalCount *atomic.Int64
|
||||||
|
fiveXXCount *atomic.Int64
|
||||||
|
rec *lib.LatencyRecorder
|
||||||
|
breaker *lib.CircuitBreaker
|
||||||
|
baseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { register("S2", newS2) }
|
||||||
|
|
||||||
|
func newS2(c *http.Client, u []lib.TestUser, e, t, f *atomic.Int64, r *lib.LatencyRecorder, b *lib.CircuitBreaker, ssh string) Scenario {
|
||||||
|
return &s2Read{client: c, users: u, errCount: e, totalCount: t, fiveXXCount: f, rec: r, breaker: b, baseURL: DefaultBaseURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s2Read) Run(ctx context.Context, rpsOverride int, durationOverride time.Duration, dash *lib.Dashboard, breaker *lib.CircuitBreaker, stages []int) error {
|
||||||
|
targetRPS := rpsOverride
|
||||||
|
if targetRPS == 0 {
|
||||||
|
targetRPS = 250
|
||||||
|
}
|
||||||
|
duration := durationOverride
|
||||||
|
if duration == 0 {
|
||||||
|
duration = 2 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(time.Second / time.Duration(targetRPS))
|
||||||
|
defer ticker.Stop()
|
||||||
|
timeout := time.NewTimer(duration)
|
||||||
|
defer timeout.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-timeout.C:
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
u := s.users[rand.Intn(len(s.users))]
|
||||||
|
if len(u.AssetIDs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assetID := u.AssetIDs[rand.Intn(len(u.AssetIDs))]
|
||||||
|
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/api/v1/assets/%d", s.baseURL, assetID), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+u.JWTToken)
|
||||||
|
doRequest(s.client, req, s.rec, s.errCount, s.totalCount, s.fiveXXCount, s.breaker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
backend/scripts/loadgen/loadgen/scenarios/s3_like.go
Normal file
75
backend/scripts/loadgen/loadgen/scenarios/s3_like.go
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
package scenarios
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/scripts/loadgen/loadgen/lib"
|
||||||
|
)
|
||||||
|
|
||||||
|
type s3Like struct {
|
||||||
|
client *http.Client
|
||||||
|
users []lib.TestUser
|
||||||
|
errCount *atomic.Int64
|
||||||
|
totalCount *atomic.Int64
|
||||||
|
fiveXXCount *atomic.Int64
|
||||||
|
rec *lib.LatencyRecorder
|
||||||
|
breaker *lib.CircuitBreaker
|
||||||
|
baseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { register("S3", newS3) }
|
||||||
|
|
||||||
|
func newS3(c *http.Client, u []lib.TestUser, e, t, f *atomic.Int64, r *lib.LatencyRecorder, b *lib.CircuitBreaker, ssh string) Scenario {
|
||||||
|
return &s3Like{client: c, users: u, errCount: e, totalCount: t, fiveXXCount: f, rec: r, breaker: b, baseURL: DefaultBaseURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s3Like) Run(ctx context.Context, rpsOverride int, durationOverride time.Duration, dash *lib.Dashboard, breaker *lib.CircuitBreaker, stages []int) error {
|
||||||
|
targetRPS := rpsOverride
|
||||||
|
if targetRPS == 0 {
|
||||||
|
targetRPS = 50
|
||||||
|
}
|
||||||
|
duration := durationOverride
|
||||||
|
if duration == 0 {
|
||||||
|
duration = 2 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(time.Second / time.Duration(targetRPS))
|
||||||
|
defer ticker.Stop()
|
||||||
|
timeout := time.NewTimer(duration)
|
||||||
|
defer timeout.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-timeout.C:
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
u := s.users[rand.Intn(len(s.users))]
|
||||||
|
if len(u.ExhibitionIDs) == 0 || len(u.AssetIDs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 50% like, 50% unlike
|
||||||
|
if rand.Float64() < 0.5 {
|
||||||
|
exID := u.ExhibitionIDs[rand.Intn(len(u.ExhibitionIDs))]
|
||||||
|
body := fmt.Sprintf(`{"exhibition_id":%d}`, exID)
|
||||||
|
assetID := u.AssetIDs[rand.Intn(2)] // asset 1, 2 (上<>架的)
|
||||||
|
req, _ := http.NewRequest("POST", fmt.Sprintf("%s/api/v1/social/assets/%d/like", s.baseURL, assetID), strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+u.JWTToken)
|
||||||
|
doRequest(s.client, req, s.rec, s.errCount, s.totalCount, s.fiveXXCount, s.breaker)
|
||||||
|
} else {
|
||||||
|
assetID := u.AssetIDs[rand.Intn(2)]
|
||||||
|
req, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/api/v1/social/assets/%d/like", s.baseURL, assetID), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+u.JWTToken)
|
||||||
|
doRequest(s.client, req, s.rec, s.errCount, s.totalCount, s.fiveXXCount, s.breaker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
backend/scripts/loadgen/loadgen/scenarios/s4_mint.go
Normal file
86
backend/scripts/loadgen/loadgen/scenarios/s4_mint.go
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
package scenarios
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/scripts/loadgen/loadgen/lib"
|
||||||
|
)
|
||||||
|
|
||||||
|
type s4Mint struct {
|
||||||
|
client *http.Client
|
||||||
|
users []lib.TestUser
|
||||||
|
errCount *atomic.Int64
|
||||||
|
totalCount *atomic.Int64
|
||||||
|
fiveXXCount *atomic.Int64
|
||||||
|
rec *lib.LatencyRecorder
|
||||||
|
breaker *lib.CircuitBreaker
|
||||||
|
prodSSH string
|
||||||
|
baseURL string
|
||||||
|
roundIdx int
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { register("S4", newS4) }
|
||||||
|
|
||||||
|
func newS4(c *http.Client, u []lib.TestUser, e, t, f *atomic.Int64, r *lib.LatencyRecorder, b *lib.CircuitBreaker, ssh string) Scenario {
|
||||||
|
return &s4Mint{client: c, users: u, errCount: e, totalCount: t, fiveXXCount: f, rec: r, breaker: b, prodSSH: ssh, baseURL: DefaultBaseURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s4Mint) Run(ctx context.Context, rpsOverride int, durationOverride time.Duration, dash *lib.Dashboard, breaker *lib.CircuitBreaker, stages []int) error {
|
||||||
|
if len(stages) == 0 {
|
||||||
|
stages = []int{5, 10, 20, 30, 50, 80}
|
||||||
|
}
|
||||||
|
for stageIdx, stageRPS := range stages {
|
||||||
|
logf("S4 stage %d/%d: %d RPS × 2min", stageIdx+1, len(stages), stageRPS)
|
||||||
|
if err := s.runStage(ctx, stageRPS, 2*time.Minute); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logf("S4 stage %d done, resetting mint data...", stageIdx+1)
|
||||||
|
if s.prodSSH != "" {
|
||||||
|
cmd := exec.Command("ssh", s.prodSSH, "bash /opt/topfans/loadtest/scripts/mint_reset.sh")
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
logf("⚠️ mint reset failed: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.roundIdx++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s4Mint) runStage(ctx context.Context, rps int, duration time.Duration) error {
|
||||||
|
ticker := time.NewTicker(time.Second / time.Duration(rps))
|
||||||
|
defer ticker.Stop()
|
||||||
|
timeout := time.NewTimer(duration)
|
||||||
|
defer timeout.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-timeout.C:
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
u := s.users[rand.Intn(len(s.users))]
|
||||||
|
s.doMint(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s4Mint) doMint(u lib.TestUser) {
|
||||||
|
name := fmt.Sprintf("loadtest_mint_%d_round%d_%d", u.UserID, s.roundIdx, time.Now().UnixNano())
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"material_url": lib.LoadtestPlaceholderURL(),
|
||||||
|
"name": name,
|
||||||
|
"info": "loadtest",
|
||||||
|
})
|
||||||
|
req, _ := http.NewRequest("POST", s.baseURL+"/api/v1/assets/mints/precreate", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+u.JWTToken)
|
||||||
|
doRequest(s.client, req, s.rec, s.errCount, s.totalCount, s.fiveXXCount, s.breaker)
|
||||||
|
}
|
||||||
76
backend/scripts/loadgen/loadgen/scenarios/s5_dashboard.go
Normal file
76
backend/scripts/loadgen/loadgen/scenarios/s5_dashboard.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package scenarios
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/scripts/loadgen/loadgen/lib"
|
||||||
|
)
|
||||||
|
|
||||||
|
var s5Endpoints = []string{
|
||||||
|
"/api/v1/dashboard/today-overview",
|
||||||
|
"/api/v1/dashboard/income-curve",
|
||||||
|
"/api/v1/dashboard/exhibition-summary",
|
||||||
|
"/api/v1/dashboard/like-income-by-level",
|
||||||
|
"/api/v1/dashboard/top-assets",
|
||||||
|
"/api/v1/dashboard/level-distribution",
|
||||||
|
"/api/v1/dashboard/upgrade-progress",
|
||||||
|
}
|
||||||
|
|
||||||
|
type s5Dashboard struct {
|
||||||
|
client *http.Client
|
||||||
|
users []lib.TestUser
|
||||||
|
errCount *atomic.Int64
|
||||||
|
totalCount *atomic.Int64
|
||||||
|
fiveXXCount *atomic.Int64
|
||||||
|
rec *lib.LatencyRecorder
|
||||||
|
breaker *lib.CircuitBreaker
|
||||||
|
baseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { register("S5", newS5) }
|
||||||
|
|
||||||
|
func newS5(c *http.Client, u []lib.TestUser, e, t, f *atomic.Int64, r *lib.LatencyRecorder, b *lib.CircuitBreaker, ssh string) Scenario {
|
||||||
|
return &s5Dashboard{client: c, users: u, errCount: e, totalCount: t, fiveXXCount: f, rec: r, breaker: b, baseURL: DefaultBaseURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s5Dashboard) Run(ctx context.Context, rpsOverride int, durationOverride time.Duration, dash *lib.Dashboard, breaker *lib.CircuitBreaker, stages []int) error {
|
||||||
|
targetRPS := rpsOverride
|
||||||
|
if targetRPS == 0 {
|
||||||
|
targetRPS = 20 // 用户会话/秒 × 7 = 140 backend QPS
|
||||||
|
}
|
||||||
|
duration := durationOverride
|
||||||
|
if duration == 0 {
|
||||||
|
duration = 2 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(time.Second / time.Duration(targetRPS))
|
||||||
|
defer ticker.Stop()
|
||||||
|
timeout := time.NewTimer(duration)
|
||||||
|
defer timeout.Stop()
|
||||||
|
i := 0
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-timeout.C:
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
u := s.users[i%len(s.users)]
|
||||||
|
i++
|
||||||
|
s.doSession(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s5Dashboard) doSession(u lib.TestUser) {
|
||||||
|
sessionStart := time.Now()
|
||||||
|
for _, ep := range s5Endpoints {
|
||||||
|
req, _ := http.NewRequest("GET", s.baseURL+ep, nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+u.JWTToken)
|
||||||
|
doRequest(s.client, req, s.rec, s.errCount, s.totalCount, s.fiveXXCount, s.breaker)
|
||||||
|
}
|
||||||
|
s.rec.Record(time.Since(sessionStart).Microseconds())
|
||||||
|
}
|
||||||
72
backend/scripts/loadgen/loadgen/scenarios/s6_ranking.go
Normal file
72
backend/scripts/loadgen/loadgen/scenarios/s6_ranking.go
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
package scenarios
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/scripts/loadgen/loadgen/lib"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
s6Dimensions = []string{"displaying", "month", "total"}
|
||||||
|
s6StarIDs = []int64{87, 88, 93, 999900}
|
||||||
|
s6Endpoints = []string{"/api/v1/rankings/hot", "/api/v1/rankings/original"}
|
||||||
|
)
|
||||||
|
|
||||||
|
type s6Ranking struct {
|
||||||
|
client *http.Client
|
||||||
|
users []lib.TestUser
|
||||||
|
errCount *atomic.Int64
|
||||||
|
totalCount *atomic.Int64
|
||||||
|
fiveXXCount *atomic.Int64
|
||||||
|
rec *lib.LatencyRecorder
|
||||||
|
breaker *lib.CircuitBreaker
|
||||||
|
baseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { register("S6", newS6) }
|
||||||
|
|
||||||
|
func newS6(c *http.Client, u []lib.TestUser, e, t, f *atomic.Int64, r *lib.LatencyRecorder, b *lib.CircuitBreaker, ssh string) Scenario {
|
||||||
|
return &s6Ranking{client: c, users: u, errCount: e, totalCount: t, fiveXXCount: f, rec: r, breaker: b, baseURL: DefaultBaseURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s6Ranking) Run(ctx context.Context, rpsOverride int, durationOverride time.Duration, dash *lib.Dashboard, breaker *lib.CircuitBreaker, stages []int) error {
|
||||||
|
targetRPS := rpsOverride
|
||||||
|
if targetRPS == 0 {
|
||||||
|
targetRPS = 300
|
||||||
|
}
|
||||||
|
duration := durationOverride
|
||||||
|
if duration == 0 {
|
||||||
|
duration = 2 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(time.Second / time.Duration(targetRPS))
|
||||||
|
defer ticker.Stop()
|
||||||
|
timeout := time.NewTimer(duration)
|
||||||
|
defer timeout.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-timeout.C:
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
s.doOne()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s6Ranking) doOne() {
|
||||||
|
for _, ep := range s6Endpoints {
|
||||||
|
for _, dim := range s6Dimensions {
|
||||||
|
for _, sid := range s6StarIDs {
|
||||||
|
url := fmt.Sprintf("%s%s?dimension=%s&star_id=%d&page=1&page_size=10", s.baseURL, ep, dim, sid)
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
doRequest(s.client, req, s.rec, s.errCount, s.totalCount, s.fiveXXCount, s.breaker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
76
backend/scripts/loadgen/loadgen/scenarios/s7_place.go
Normal file
76
backend/scripts/loadgen/loadgen/scenarios/s7_place.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package scenarios
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/scripts/loadgen/loadgen/lib"
|
||||||
|
)
|
||||||
|
|
||||||
|
type s7Place struct {
|
||||||
|
client *http.Client
|
||||||
|
users []lib.TestUser
|
||||||
|
errCount *atomic.Int64
|
||||||
|
totalCount *atomic.Int64
|
||||||
|
fiveXXCount *atomic.Int64
|
||||||
|
rec *lib.LatencyRecorder
|
||||||
|
breaker *lib.CircuitBreaker
|
||||||
|
baseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { register("S7", newS7) }
|
||||||
|
|
||||||
|
func newS7(c *http.Client, u []lib.TestUser, e, t, f *atomic.Int64, r *lib.LatencyRecorder, b *lib.CircuitBreaker, ssh string) Scenario {
|
||||||
|
return &s7Place{client: c, users: u, errCount: e, totalCount: t, fiveXXCount: f, rec: r, breaker: b, baseURL: DefaultBaseURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s7Place) Run(ctx context.Context, rpsOverride int, durationOverride time.Duration, dash *lib.Dashboard, breaker *lib.CircuitBreaker, stages []int) error {
|
||||||
|
targetRPS := rpsOverride
|
||||||
|
if targetRPS == 0 {
|
||||||
|
targetRPS = 50
|
||||||
|
}
|
||||||
|
duration := durationOverride
|
||||||
|
if duration == 0 {
|
||||||
|
duration = 2 * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(time.Second / time.Duration(targetRPS))
|
||||||
|
defer ticker.Stop()
|
||||||
|
timeout := time.NewTimer(duration)
|
||||||
|
defer timeout.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-timeout.C:
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
u := s.users[rand.Intn(len(s.users))]
|
||||||
|
s.doPlace(u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *s7Place) doPlace(u lib.TestUser) {
|
||||||
|
if u.SlotID3 == 0 || len(u.AssetIDs) < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rand.Float64() < 0.5 {
|
||||||
|
assetID := u.AssetIDs[2+rand.Intn(3)]
|
||||||
|
body, _ := json.Marshal(map[string]any{"slot_id": u.SlotID3, "asset_id": assetID})
|
||||||
|
req, _ := http.NewRequest("POST", s.baseURL+"/api/v1/galleries/place", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+u.JWTToken)
|
||||||
|
doRequest(s.client, req, s.rec, s.errCount, s.totalCount, s.fiveXXCount, s.breaker)
|
||||||
|
} else {
|
||||||
|
req, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/api/v1/galleries/slots/%d/asset", s.baseURL, u.SlotID3), nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+u.JWTToken)
|
||||||
|
doRequest(s.client, req, s.rec, s.errCount, s.totalCount, s.fiveXXCount, s.breaker)
|
||||||
|
}
|
||||||
|
}
|
||||||
29
backend/scripts/loadgen/loadgen/scenarios/scenarios.go
Normal file
29
backend/scripts/loadgen/loadgen/scenarios/scenarios.go
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package scenarios
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/scripts/loadgen/loadgen/lib"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Scenario interface {
|
||||||
|
Run(ctx context.Context, rpsOverride int, durationOverride time.Duration, dash *lib.Dashboard, breaker *lib.CircuitBreaker, stages []int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var registry = map[string]func(client *http.Client, users []lib.TestUser, errCount, totalCount, fiveXXCount *atomic.Int64, rec *lib.LatencyRecorder, breaker *lib.CircuitBreaker, prodSSH string) Scenario{}
|
||||||
|
|
||||||
|
func Get(id string, client *http.Client, users []lib.TestUser, errCount, totalCount, fiveXXCount *atomic.Int64, rec *lib.LatencyRecorder, breaker *lib.CircuitBreaker, prodSSH string) (Scenario, error) {
|
||||||
|
factory, ok := registry[id]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown scenario: %s", id)
|
||||||
|
}
|
||||||
|
return factory(client, users, errCount, totalCount, fiveXXCount, rec, breaker, prodSSH), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func register(id string, factory func(*http.Client, []lib.TestUser, *atomic.Int64, *atomic.Int64, *atomic.Int64, *lib.LatencyRecorder, *lib.CircuitBreaker, string) Scenario) {
|
||||||
|
registry[id] = factory
|
||||||
|
}
|
||||||
51
backend/scripts/loadgen/loadgen/scenarios/scenarios_test.go
Normal file
51
backend/scripts/loadgen/loadgen/scenarios/scenarios_test.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package scenarios
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAllScenariosRegistered(t *testing.T) {
|
||||||
|
expected := []string{"S1", "S2", "S3", "S4", "S5", "S6", "S7"}
|
||||||
|
for _, id := range expected {
|
||||||
|
if _, ok := registry[id]; !ok {
|
||||||
|
t.Errorf("scenario %s not registered", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMintNameFormat(t *testing.T) {
|
||||||
|
name := mintName(30000001, 3)
|
||||||
|
if !strings.HasPrefix(name, "loadtest_mint_") {
|
||||||
|
t.Errorf("name %q must start with loadtest_mint_", name)
|
||||||
|
}
|
||||||
|
if !strings.Contains(name, "30000001") {
|
||||||
|
t.Errorf("name %q must contain user id", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mintName(uid int64, round int) string {
|
||||||
|
return "loadtest_mint_" + itoa(uid) + "_round" + itoa(int64(round))
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int64) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
neg := n < 0
|
||||||
|
if neg {
|
||||||
|
n = -n
|
||||||
|
}
|
||||||
|
var buf [20]byte
|
||||||
|
i := len(buf)
|
||||||
|
for n > 0 {
|
||||||
|
i--
|
||||||
|
buf[i] = byte('0' + n%10)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
i--
|
||||||
|
buf[i] = '-'
|
||||||
|
}
|
||||||
|
return string(buf[i:])
|
||||||
|
}
|
||||||
35
backend/scripts/loadgen/loadgen/verify.go
Normal file
35
backend/scripts/loadgen/loadgen/verify.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runVerify(prodSSH string) error {
|
||||||
|
if prodSSH == "" {
|
||||||
|
return fmt.Errorf("prod-ssh required for verify")
|
||||||
|
}
|
||||||
|
pre := sshPG(prodSSH, "SELECT count(*) FROM mint_orders WHERE star_id != 999900")
|
||||||
|
fmt.Printf(" real mint_orders (non-loadtest): %s\n", pre)
|
||||||
|
|
||||||
|
conn := sshPG(prodSSH, "SELECT count(*) FROM pg_stat_activity")
|
||||||
|
fmt.Printf(" PG active conn: %s\n", conn)
|
||||||
|
|
||||||
|
out, err := exec.Command("ssh", prodSSH, "docker ps --format '{{.Names}} {{.Status}}'").Output()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("docker ps: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf(" 容器状态:\n%s\n", out)
|
||||||
|
|
||||||
|
disk := sshPG(prodSSH, "")
|
||||||
|
_ = disk
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sshPG(ssh, query string) string {
|
||||||
|
cmd := exec.Command("ssh", ssh,
|
||||||
|
fmt.Sprintf(`export PGPASSWORD="${DB_PASSWORD:-postgres123}"; PG=$(docker ps --filter 'name=postgres' --format '{{.Names}}' | grep -v exporter | head -1); docker exec -e PGPASSWORD="$PGPASSWORD" "$PG" psql -U postgres -d topfans -t -c "%s"`, query))
|
||||||
|
out, _ := cmd.Output()
|
||||||
|
return strings.TrimSpace(string(out))
|
||||||
|
}
|
||||||
61
backend/scripts/loadgen/monitor/docker-compose.monitor.yml
Normal file
61
backend/scripts/loadgen/monitor/docker-compose.monitor.yml
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
cadvisor:
|
||||||
|
image: gcr.io/cadvisor/cadvisor:v0.47.0
|
||||||
|
container_name: topfans-cadvisor
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- /:/rootfs:ro
|
||||||
|
- /var/run:/var/run:ro
|
||||||
|
- /sys:/sys:ro
|
||||||
|
- /var/lib/docker/:/var/lib/docker:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
node-exporter:
|
||||||
|
image: prom/node-exporter:v1.7.0
|
||||||
|
container_name: topfans-node-exporter
|
||||||
|
network_mode: host
|
||||||
|
pid: host
|
||||||
|
volumes:
|
||||||
|
- /proc:/host/proc:ro
|
||||||
|
- /sys:/host/sys:ro
|
||||||
|
- /:/rootfs:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres-exporter:
|
||||||
|
image: prometheuscommunity/postgres-exporter:v0.13.2
|
||||||
|
container_name: topfans-pg-exporter
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
DATA_SOURCE_NAME: "postgresql://postgres:${DB_PASSWORD:-postgres123}@localhost:5432/topfans?sslmode=disable"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis-exporter:
|
||||||
|
image: oliver006/redis_exporter:v1.58.0
|
||||||
|
container_name: topfans-redis-exporter
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
REDIS_ADDR: "redis://localhost:6379"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v2.51.2
|
||||||
|
container_name: topfans-prometheus
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:10.4.2
|
||||||
|
container_name: topfans-grafana
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_PASSWORD:-admin}"
|
||||||
|
volumes:
|
||||||
|
- grafana-data:/var/lib/grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
grafana-data:
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"title": "Load Test - Host Overview",
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"title": "CPU Usage",
|
||||||
|
"type": "graph",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "100 - (avg by(instance)(rate(node_cpu_seconds_total{mode=\"idle\"}[1m])) * 100)" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Memory Usage",
|
||||||
|
"type": "graph",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "node_memory_MemTotal_bytes - node_memory_MemAvailable_bytes" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Load Average",
|
||||||
|
"type": "graph",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "node_load1" },
|
||||||
|
{ "expr": "node_load5" },
|
||||||
|
{ "expr": "node_load15" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"title": "Load Test - Containers",
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"title": "Container CPU",
|
||||||
|
"type": "graph",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "rate(container_cpu_usage_seconds_total{name=~\"topfans-.*\"}[1m]) * 100" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Container Memory",
|
||||||
|
"type": "graph",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "container_memory_usage_bytes{name=~\"topfans-.*\"}" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Container Restart Count",
|
||||||
|
"type": "graph",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "kube_pod_container_status_restarts_total" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"title": "Load Test - Postgres Health",
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"title": "Active Connections",
|
||||||
|
"type": "graph",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "pg_stat_activity_count" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Cache Hit Ratio",
|
||||||
|
"type": "graph",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "rate(pg_stat_database_blks_hit[5m]) / (rate(pg_stat_database_blks_hit[5m]) + rate(pg_stat_database_blks_read[5m]))" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Longest Transaction",
|
||||||
|
"type": "graph",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "pg_stat_activity_max_tx_duration" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"title": "Load Test - Business",
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"title": "RPS (per scenario)",
|
||||||
|
"type": "graph",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "rate(loadgen_requests_total[1m])" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Error Rate",
|
||||||
|
"type": "graph",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "rate(loadgen_errors_total[1m]) / rate(loadgen_requests_total[1m])" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "P99 Latency (ms)",
|
||||||
|
"type": "graph",
|
||||||
|
"targets": [
|
||||||
|
{ "expr": "loadgen_p99_ms" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
20
backend/scripts/loadgen/monitor/prometheus.yml
Normal file
20
backend/scripts/loadgen/monitor/prometheus.yml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
global:
|
||||||
|
scrape_interval: 5s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: cadvisor
|
||||||
|
static_configs:
|
||||||
|
- targets: ["localhost:8088"]
|
||||||
|
- job_name: node
|
||||||
|
static_configs:
|
||||||
|
- targets: ["localhost:9100"]
|
||||||
|
- job_name: postgres
|
||||||
|
static_configs:
|
||||||
|
- targets: ["localhost:9187"]
|
||||||
|
- job_name: redis
|
||||||
|
static_configs:
|
||||||
|
- targets: ["localhost:9121"]
|
||||||
|
- job_name: loadgen
|
||||||
|
metrics_path: /metrics
|
||||||
|
static_configs:
|
||||||
|
- targets: ["localhost:9091"]
|
||||||
28
backend/scripts/loadgen/monitor/sample.sh
Normal file
28
backend/scripts/loadgen/monitor/sample.sh
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# /opt/topfans/loadtest/monitor/sample.sh
|
||||||
|
# 后台采样,写到 metrics-feed.jsonl
|
||||||
|
set -e
|
||||||
|
|
||||||
|
OUT="/opt/topfans/loadtest/metrics-feed.jsonl"
|
||||||
|
INTERVAL=${INTERVAL:-5}
|
||||||
|
PG_CONTAINER=$(docker ps --filter 'name=postgres' --format '{{.Names}}' | grep -v exporter | head -1)
|
||||||
|
[ -z "$PG_CONTAINER" ] && { echo "❌ 找不到 postgres 容器"; exit 1; }
|
||||||
|
export PGPASSWORD="${DB_PASSWORD:-postgres123}"
|
||||||
|
|
||||||
|
echo "📊 sampling to $OUT every ${INTERVAL}s, pid $$"
|
||||||
|
: > "$OUT"
|
||||||
|
while true; do
|
||||||
|
TS=$(date +%s)
|
||||||
|
PG_ACTIVE=$(docker exec -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -d topfans -tA -c "SELECT count(*) FROM pg_stat_activity WHERE state='active'" 2>/dev/null || echo 0)
|
||||||
|
DISK_FREE=$(df -B1G /opt | tail -1 | awk '{print $4}')
|
||||||
|
OOM=$(docker events --filter event=oom --since 5s --until 0s --format '{{.Actor.Attributes.name}}' 2>/dev/null | head -1)
|
||||||
|
if [ -n "$OOM" ]; then
|
||||||
|
OOM_FLAG="true"
|
||||||
|
OOM_NAME="$OOM"
|
||||||
|
else
|
||||||
|
OOM_FLAG="false"
|
||||||
|
OOM_NAME=""
|
||||||
|
fi
|
||||||
|
echo "$TS pg_active=$PG_ACTIVE disk_free=$DISK_FREE oom=$OOM_FLAG oom_container=$OOM_NAME" >> "$OUT"
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
done
|
||||||
28
backend/scripts/loadgen/recover/emergency-stop.sh
Normal file
28
backend/scripts/loadgen/recover/emergency-stop.sh
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# /opt/topfans/loadtest/recover/emergency-stop.sh
|
||||||
|
# 一键灭火
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚨 emergency stop"
|
||||||
|
pkill -9 loadgen 2>/dev/null || true
|
||||||
|
|
||||||
|
PG_CONTAINER=$(docker ps --filter 'name=postgres' --format '{{.Names}}' | grep -v exporter | head -1)
|
||||||
|
[ -z "$PG_CONTAINER" ] && { echo "❌ 找不到 postgres 容器"; exit 1; }
|
||||||
|
export PGPASSWORD="${DB_PASSWORD:-postgres123}"
|
||||||
|
|
||||||
|
docker exec -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -d topfans -c "
|
||||||
|
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
|
||||||
|
WHERE state != 'idle' AND now() - query_start > interval '10 seconds'
|
||||||
|
AND usename = 'postgres';
|
||||||
|
" || true
|
||||||
|
|
||||||
|
cd /opt/topfans/docker || { echo "❌ 找不到 /opt/topfans/docker"; exit 1; }
|
||||||
|
docker-compose -f docker-compose.prod.yml restart
|
||||||
|
|
||||||
|
sleep 30
|
||||||
|
if curl -fs http://localhost:8080/health; then
|
||||||
|
echo "✅ gateway 恢复"
|
||||||
|
else
|
||||||
|
echo "⚠️ gateway 仍未恢复"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
30
backend/scripts/loadgen/recover/restore-from-backup.sh
Normal file
30
backend/scripts/loadgen/recover/restore-from-backup.sh
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# /opt/topfans/loadtest/recover/restore-from-backup.sh
|
||||||
|
# 用法: bash restore-from-backup.sh /opt/topfans/backups/pre-loadtest-XXXX.sql
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BACKUP_FILE=$1
|
||||||
|
[ -f "$BACKUP_FILE" ] || { echo "❌ 备份文件不存在: $BACKUP_FILE"; exit 1; }
|
||||||
|
|
||||||
|
PG_CONTAINER=$(docker ps --filter 'name=postgres' --format '{{.Names}}' | grep -v exporter | head -1)
|
||||||
|
[ -z "$PG_CONTAINER" ] && { echo "❌ 找不到 postgres 容器"; exit 1; }
|
||||||
|
export PGPASSWORD="${DB_PASSWORD:-postgres123}"
|
||||||
|
|
||||||
|
echo "🛑 停应用层..."
|
||||||
|
docker ps --filter 'name=topfans-' --format '{{.Names}}' \
|
||||||
|
| grep -v postgres | grep -v redis | grep -v exporter \
|
||||||
|
| xargs -r docker stop
|
||||||
|
|
||||||
|
echo "🗑️ 删库重建..."
|
||||||
|
docker exec -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -c "DROP DATABASE topfans;"
|
||||||
|
docker exec -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -c "CREATE DATABASE topfans;"
|
||||||
|
|
||||||
|
echo "📥 还原 $BACKUP_FILE ..."
|
||||||
|
docker exec -i -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -d topfans < "$BACKUP_FILE"
|
||||||
|
|
||||||
|
echo "🚀 启动应用层..."
|
||||||
|
cd /opt/topfans/docker || { echo "❌ 找不到 /opt/topfans/docker"; exit 1; }
|
||||||
|
docker-compose -f docker-compose.prod.yml --profile prod up -d
|
||||||
|
|
||||||
|
sleep 30
|
||||||
|
curl -fs http://localhost:8080/health && echo "✅ 恢复完成"
|
||||||
19
backend/scripts/loadgen/scripts/mint_reset.sh
Normal file
19
backend/scripts/loadgen/scripts/mint_reset.sh
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# /opt/topfans/loadtest/scripts/mint_reset.sh
|
||||||
|
# 重置铸造场景数据(用户水晶/铸造次数/订单)
|
||||||
|
set -e
|
||||||
|
|
||||||
|
PG_CONTAINER=$(docker ps --filter 'name=postgres' --format '{{.Names}}' | grep -v exporter | head -1)
|
||||||
|
[ -z "$PG_CONTAINER" ] && { echo "❌ 找不到 postgres 容器"; exit 1; }
|
||||||
|
export PGPASSWORD="${DB_PASSWORD:-postgres123}"
|
||||||
|
|
||||||
|
docker exec -i -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -d topfans <<'EOF'
|
||||||
|
BEGIN;
|
||||||
|
DELETE FROM user_mint_count WHERE star_id = 999900;
|
||||||
|
DELETE FROM mint_orders WHERE star_id = 999900;
|
||||||
|
UPDATE fan_profiles SET crystal_balance = 2200 WHERE star_id = 999900;
|
||||||
|
DELETE FROM crystal_transaction_records WHERE star_id = 999900;
|
||||||
|
DELETE FROM assets WHERE star_id = 999900 AND name LIKE 'loadtest_mint_%';
|
||||||
|
COMMIT;
|
||||||
|
EOF
|
||||||
|
echo "✅ mint reset 完成"
|
||||||
67
backend/scripts/loadgen/seed/README.md
Normal file
67
backend/scripts/loadgen/seed/README.md
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
# seed - 压测数据准备工具
|
||||||
|
|
||||||
|
## 用途
|
||||||
|
|
||||||
|
在 prod 本地插入 1000 个测试用户、5000 资产、3000 booth_slots、2000 exhibitions、10000 friendships,签 1000 个 JWT,写 `users.csv`。
|
||||||
|
|
||||||
|
## 编译
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && go build -o seed ./scripts/loadgen/seed/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 在 prod 上跑
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 上传二进制
|
||||||
|
scp seed root@101.132.250.62:/opt/topfans/loadtest/
|
||||||
|
|
||||||
|
# 2. SSH 上去跑
|
||||||
|
ssh root@101.132.250.62
|
||||||
|
cd /opt/topfans/loadtest
|
||||||
|
export DB_PASSWORD=$(cat /opt/topfans/docker/.env.prod | grep DB_PASSWORD | cut -d= -f2)
|
||||||
|
export JWT_SECRET=$(cat /opt/topfans/docker/.env.prod | grep JWT_SECRET | cut -d= -f2)
|
||||||
|
./seed --db-name=topfans --jwt-secret="$JWT_SECRET"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 清理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 保留 1000 users + 资产(下次复用)
|
||||||
|
./seed --cleanup
|
||||||
|
|
||||||
|
# 全删(包括账号本身)
|
||||||
|
./seed --cleanup --full
|
||||||
|
|
||||||
|
# 只重签 token(第二轮压测 JWT 过期时)
|
||||||
|
./seed --reset-tokens --jwt-secret="$JWT_SECRET"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地 docker 联调(开发阶段)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
go build -o bin/seed ./scripts/loadgen/seed/
|
||||||
|
DB_PASSWORD=postgres123 JWT_SECRET=topfans-secret-key-local-dev-only \
|
||||||
|
./bin/seed --db-name=top-fans --db-host=localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键约束
|
||||||
|
|
||||||
|
- **star_id = 999900**:所有数据用此 star_id 隔离,不影响真实业务
|
||||||
|
- **PG max_connections = 50**:Task 5 已将 `POSTGRES_MAX_CONNECTIONS` 从 100 改到 50
|
||||||
|
- **CLAUDE.md 序列重置**:ResetSequences 会在 seed 末尾自动同步所有相关表的 sequence,避免后续 GORM 插入报 duplicate key
|
||||||
|
- **JWT 7 天过期**:跨周第二轮压测前需 `--reset-tokens` 重签
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && go test ./scripts/loadgen/seed/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
5 个测试:
|
||||||
|
- `TestMobileNumbering`:mobile 编号正确性
|
||||||
|
- `TestSequenceMapping`:loadtestSeqs 映射
|
||||||
|
- `TestPKColumnMapping`:pkColumns 映射(关键 stars/star_id, booth_slots/slot_id)
|
||||||
|
- `TestCleanupRejectsInvalidStarID`:cleanup 拒绝非 loadtest star_id
|
||||||
|
- `TestJoinInt64`:CSV 序列化辅助函数
|
||||||
51
backend/scripts/loadgen/seed/assets.go
Normal file
51
backend/scripts/loadgen/seed/assets.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
LoadtestPlaceholderURL = "<OSS_URL>/loadtest-placeholder.png" // TODO: Task 48 上传 OSS 后替换
|
||||||
|
AssetsPerUser = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
func SeedAssets(db *sql.DB) error {
|
||||||
|
ts := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
var maxID int64
|
||||||
|
if err := db.QueryRow("SELECT COALESCE(MAX(id), 0) FROM assets").Scan(&maxID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
startID := maxID + 1000
|
||||||
|
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare(`
|
||||||
|
INSERT INTO assets (id, owner_uid, star_id, name, cover_url, info, status, like_count, is_active, created_at, updated_at, grade)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'loadtest', 1, 0, true, $6, $6, 1)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
n := int64(0)
|
||||||
|
for uid := LoadtestUserMin; uid <= LoadtestUserMax; uid++ {
|
||||||
|
for i := 1; i <= AssetsPerUser; i++ {
|
||||||
|
aid := startID + n
|
||||||
|
name := fmt.Sprintf("loadtest_asset_%d_%d", uid, i)
|
||||||
|
if _, err := stmt.Exec(aid, uid, LoadtestStarID, name, LoadtestPlaceholderURL, ts); err != nil {
|
||||||
|
return fmt.Errorf("insert asset %d: %w", aid, err)
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
36
backend/scripts/loadgen/seed/cleanup.go
Normal file
36
backend/scripts/loadgen/seed/cleanup.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Cleanup(db *sql.DB, starID int64, full bool) error {
|
||||||
|
if starID != LoadtestStarID {
|
||||||
|
return errors.New("safety: cleanup only accepts loadtest star_id 999900")
|
||||||
|
}
|
||||||
|
|
||||||
|
queries := []string{
|
||||||
|
"DELETE FROM asset_likes WHERE star_id = $1",
|
||||||
|
"DELETE FROM exhibitions USING fan_profiles fp WHERE exhibitions.host_profile_id = fp.id AND fp.star_id = $1",
|
||||||
|
"DELETE FROM booth_slots WHERE star_id = $1",
|
||||||
|
"DELETE FROM mint_orders WHERE star_id = $1",
|
||||||
|
"DELETE FROM crystal_transaction_records WHERE star_id = $1",
|
||||||
|
"DELETE FROM friendships WHERE star_id = $1",
|
||||||
|
"DELETE FROM assets WHERE star_id = $1",
|
||||||
|
"DELETE FROM fan_profiles WHERE star_id = $1",
|
||||||
|
}
|
||||||
|
if full {
|
||||||
|
queries = append(queries,
|
||||||
|
"DELETE FROM users WHERE id BETWEEN $2 AND $3",
|
||||||
|
"DELETE FROM stars WHERE star_id = $1",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for _, q := range queries {
|
||||||
|
if _, err := db.Exec(q, starID, LoadtestUserMin, LoadtestUserMax); err != nil {
|
||||||
|
return fmt.Errorf("cleanup %q: %w", q[:30], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ResetSequences(db)
|
||||||
|
}
|
||||||
21
backend/scripts/loadgen/seed/friendships.go
Normal file
21
backend/scripts/loadgen/seed/friendships.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SeedFriendships(db *sql.DB) error {
|
||||||
|
ts := time.Now().UnixMilli()
|
||||||
|
_, err := db.Exec(`
|
||||||
|
INSERT INTO friendships (user_id, friend_id, star_id, status, intimacy, created_at, updated_at)
|
||||||
|
SELECT a.id, b.id, $1, 'accepted', 0, $2, $2
|
||||||
|
FROM users a, users b
|
||||||
|
WHERE a.id BETWEEN $3 AND $4
|
||||||
|
AND b.id BETWEEN $3 AND $4
|
||||||
|
AND a.id != b.id
|
||||||
|
AND ((a.id - $3) + 1) % 10 = ((b.id - $3) % 10)
|
||||||
|
ON CONFLICT (user_id, friend_id, star_id) DO NOTHING
|
||||||
|
`, LoadtestStarID, ts, LoadtestUserMin, LoadtestUserMax)
|
||||||
|
return err
|
||||||
|
}
|
||||||
1
backend/scripts/loadgen/seed/loadtest_bcrypt.txt
Normal file
1
backend/scripts/loadgen/seed/loadtest_bcrypt.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
$2a$10$38rspoc377zFTH3h3AeLy.qD7JxaJbw7VGSJ/Wp.hyZ0isFGJO/0m
|
||||||
34
backend/scripts/loadgen/seed/profiles.go
Normal file
34
backend/scripts/loadgen/seed/profiles.go
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SeedProfiles(db *sql.DB) error {
|
||||||
|
ts := time.Now().UnixMilli()
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare(`
|
||||||
|
INSERT INTO fan_profiles (user_id, star_id, nickname, crystal_balance, slot_limit, is_active, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, 2200, 3, true, $4, $4)
|
||||||
|
ON CONFLICT (user_id, star_id) DO NOTHING
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for uid := LoadtestUserMin; uid <= LoadtestUserMax; uid++ {
|
||||||
|
nick := fmt.Sprintf("loadtest_%d", uid-LoadtestUserMin+1)
|
||||||
|
if _, err := stmt.Exec(uid, LoadtestStarID, nick, ts); err != nil {
|
||||||
|
return fmt.Errorf("insert profile %d: %w", uid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
91
backend/scripts/loadgen/seed/seed_test.go
Normal file
91
backend/scripts/loadgen/seed/seed_test.go
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMobileNumbering(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
uid int64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{30000001, "19900000001"},
|
||||||
|
{30000050, "19900000050"},
|
||||||
|
{30001000, "19900001000"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := formatMobile(c.uid)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("formatMobile(%d) = %q, want %q", c.uid, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMobile(uid int64) string {
|
||||||
|
return fmt.Sprintf("199%08d", uid-LoadtestUserMin+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSequenceMapping(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"users": "users_id_seq",
|
||||||
|
"stars": "stars_star_id_seq",
|
||||||
|
"booth_slots": "booth_slots_slot_id_seq",
|
||||||
|
}
|
||||||
|
for tbl, want := range cases {
|
||||||
|
got, ok := loadtestSeqs[tbl]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("table %s not in loadtestSeqs", tbl)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("seq for %s = %q, want %q", tbl, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPKColumnMapping(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"users": "id",
|
||||||
|
"stars": "star_id",
|
||||||
|
"booth_slots": "slot_id",
|
||||||
|
"assets": "id",
|
||||||
|
}
|
||||||
|
for tbl, want := range cases {
|
||||||
|
got, ok := pkColumns[tbl]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("table %s not in pkColumns", tbl)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("pk for %s = %q, want %q", tbl, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupRejectsInvalidStarID(t *testing.T) {
|
||||||
|
db, _ := sql.Open("postgres", "host=localhost sslmode=disable")
|
||||||
|
defer db.Close()
|
||||||
|
err := Cleanup(db, 87, false)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for non-loadtest star_id, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJoinInt64(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in []int64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{nil, ""},
|
||||||
|
{[]int64{42}, "42"},
|
||||||
|
{[]int64{1, 2, 3}, "1;2;3"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := joinInt64(c.in)
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("joinInt64(%v) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
backend/scripts/loadgen/seed/sequences.go
Normal file
52
backend/scripts/loadgen/seed/sequences.go
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var loadtestSeqs = map[string]string{
|
||||||
|
"users": "users_id_seq",
|
||||||
|
"fan_profiles": "fan_profiles_id_seq",
|
||||||
|
"assets": "assets_id_seq",
|
||||||
|
"booth_slots": "booth_slots_slot_id_seq",
|
||||||
|
"exhibitions": "exhibitions_id_seq",
|
||||||
|
"stars": "stars_star_id_seq",
|
||||||
|
"asset_likes": "asset_likes_id_seq",
|
||||||
|
"friendships": "friendships_id_seq",
|
||||||
|
"crystal_transaction_records": "crystal_transaction_records_id_seq",
|
||||||
|
}
|
||||||
|
|
||||||
|
var pkColumns = map[string]string{
|
||||||
|
"users": "id",
|
||||||
|
"fan_profiles": "id",
|
||||||
|
"assets": "id",
|
||||||
|
"booth_slots": "slot_id",
|
||||||
|
"exhibitions": "id",
|
||||||
|
"stars": "star_id",
|
||||||
|
"asset_likes": "id",
|
||||||
|
"friendships": "id",
|
||||||
|
"crystal_transaction_records": "id",
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResetSequences(db *sql.DB) error {
|
||||||
|
for tbl, seq := range loadtestSeqs {
|
||||||
|
pk := pkColumns[tbl]
|
||||||
|
if pk == "" {
|
||||||
|
pk = "id"
|
||||||
|
}
|
||||||
|
var maxID sql.NullInt64
|
||||||
|
if err := db.QueryRow(fmt.Sprintf("SELECT MAX(%s) FROM %s", pk, tbl)).Scan(&maxID); err != nil {
|
||||||
|
fmt.Printf(" skip %s: %v\n", tbl, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !maxID.Valid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(fmt.Sprintf("SELECT setval('%s', $1)", seq), maxID.Int64); err != nil {
|
||||||
|
return fmt.Errorf("setval %s: %w", seq, err)
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✓ %s → %d\n", seq, maxID.Int64)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
55
backend/scripts/loadgen/seed/slots_and_exhibits.go
Normal file
55
backend/scripts/loadgen/seed/slots_and_exhibits.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SeedSlotsAndExhibits(db *sql.DB) error {
|
||||||
|
ts := time.Now().UnixMilli()
|
||||||
|
expire := ts + 4*3600*1000 // 4 小时
|
||||||
|
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// 1. booth_slots: 每 user 3 个, is_enabled=true
|
||||||
|
slotStmt, err := tx.Prepare(`
|
||||||
|
INSERT INTO booth_slots (host_profile_id, user_id, star_id, slot_index, is_enabled, created_at, updated_at)
|
||||||
|
SELECT fp.id, fp.user_id, fp.star_id, idx, true, $1, $1
|
||||||
|
FROM fan_profiles fp
|
||||||
|
CROSS JOIN (VALUES (1),(2),(3)) AS s(idx)
|
||||||
|
WHERE fp.star_id = $2
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer slotStmt.Close()
|
||||||
|
if _, err := slotStmt.Exec(ts, LoadtestStarID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. exhibitions: 把每个 user 的前 2 个 asset 上架到 slot 1, 2
|
||||||
|
exStmt, err := tx.Prepare(`
|
||||||
|
INSERT INTO exhibitions (asset_id, slot_id, host_profile_id, occupier_uid, occupier_star_id, start_time, expire_at, created_at, updated_at)
|
||||||
|
SELECT a.id, bs.slot_id, fp.id, a.owner_uid, $1, $2, $3, $2, $2
|
||||||
|
FROM assets a
|
||||||
|
JOIN fan_profiles fp ON a.owner_uid = fp.user_id AND fp.star_id = $1
|
||||||
|
JOIN booth_slots bs ON bs.host_profile_id = fp.id AND (bs.slot_index = $4 OR bs.slot_index = $5)
|
||||||
|
WHERE a.star_id = $1 AND (a.name LIKE '%_1' OR a.name LIKE '%_2')
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer exStmt.Close()
|
||||||
|
// asset 1 → slot 1
|
||||||
|
if _, err := exStmt.Exec(LoadtestStarID, ts, expire, 1, 2); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
98
backend/scripts/loadgen/seed/tokens.go
Normal file
98
backend/scripts/loadgen/seed/tokens.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/pkg/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TestUser struct {
|
||||||
|
UserID int64
|
||||||
|
Mobile string
|
||||||
|
AssetIDs []int64
|
||||||
|
ExhibitionIDs []int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateTokensForLoadtest(cfg *Config) error {
|
||||||
|
jwt.SetSecret(cfg.JWTSecret)
|
||||||
|
|
||||||
|
db, err := openDB(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT u.id, u.mobile,
|
||||||
|
COALESCE(array_agg(DISTINCT a.id) FILTER (WHERE a.id IS NOT NULL), '{}'),
|
||||||
|
COALESCE(array_agg(DISTINCT e.id) FILTER (WHERE e.id IS NOT NULL), '{}')
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN assets a ON a.owner_uid = u.id AND a.star_id = $1
|
||||||
|
LEFT JOIN fan_profiles fp ON fp.user_id = u.id AND fp.star_id = $1
|
||||||
|
LEFT JOIN booth_slots bs ON bs.host_profile_id = fp.id AND bs.slot_index IN (1, 2)
|
||||||
|
LEFT JOIN exhibitions e ON e.slot_id = bs.slot_id AND e.occupier_star_id = $1
|
||||||
|
WHERE u.id BETWEEN $2 AND $3
|
||||||
|
GROUP BY u.id, u.mobile
|
||||||
|
ORDER BY u.id
|
||||||
|
`, LoadtestStarID, LoadtestUserMin, LoadtestUserMax)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var users []TestUser
|
||||||
|
for rows.Next() {
|
||||||
|
var u TestUser
|
||||||
|
if err := rows.Scan(&u.UserID, &u.Mobile, &u.AssetIDs, &u.ExhibitionIDs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
users = append(users, u)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Create("users.csv")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
w := csv.NewWriter(f)
|
||||||
|
defer w.Flush()
|
||||||
|
if err := w.Write([]string{"phone", "password", "user_id", "star_id", "jwt_token", "asset_ids", "exhibition_ids"}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
for _, u := range users {
|
||||||
|
token, err := jwt.GenerateToken(u.UserID, LoadtestStarID, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := w.Write([]string{
|
||||||
|
u.Mobile, "Test@123",
|
||||||
|
strconv.FormatInt(u.UserID, 10),
|
||||||
|
"999900", token,
|
||||||
|
joinInt64(u.AssetIDs),
|
||||||
|
joinInt64(u.ExhibitionIDs),
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("✅ users.csv written: %d rows\n", len(users))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinInt64(s []int64) string {
|
||||||
|
parts := make([]string, len(s))
|
||||||
|
for i, v := range s {
|
||||||
|
parts[i] = strconv.FormatInt(v, 10)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ";")
|
||||||
|
}
|
||||||
41
backend/scripts/loadgen/seed/users.go
Normal file
41
backend/scripts/loadgen/seed/users.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bcryptHashFile = "loadtest_bcrypt.txt"
|
||||||
|
|
||||||
|
func SeedUsers(db *sql.DB) error {
|
||||||
|
hash, err := os.ReadFile(bcryptHashFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read bcrypt hash file %s: %w (run Task 10 Step 1 first)", bcryptHashFile, err)
|
||||||
|
}
|
||||||
|
ts := time.Now().UnixMilli()
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare(`
|
||||||
|
INSERT INTO users (id, mobile, password_hash, is_active, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, true, $4, $4)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for uid := LoadtestUserMin; uid <= LoadtestUserMax; uid++ {
|
||||||
|
mobile := fmt.Sprintf("199%08d", uid-LoadtestUserMin+1)
|
||||||
|
if _, err := stmt.Exec(uid, mobile, string(hash), ts); err != nil {
|
||||||
|
return fmt.Errorf("insert user %d: %w", uid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
4312
docs/superpowers/plans/2026-06-12-load-testing.md
Normal file
4312
docs/superpowers/plans/2026-06-12-load-testing.md
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user