完善数据库 SQL 约束与运维治理
This commit is contained in:
@@ -50,6 +50,8 @@ npm run dev
|
|||||||
|
|
||||||
`./scripts/deploy-prod.sh` 在构建镜像前会自动调用 `check.sh`(本地有 `go` 和 `npm` 时)。检查未通过会中止部署;确需跳过用 `--skip-check`(不推荐)。`check.sh` 也支持 `--skip-backend`、`--skip-frontend`、`--skip-tests` 做局部调试。
|
`./scripts/deploy-prod.sh` 在构建镜像前会自动调用 `check.sh`(本地有 `go` 和 `npm` 时)。检查未通过会中止部署;确需跳过用 `--skip-check`(不推荐)。`check.sh` 也支持 `--skip-backend`、`--skip-frontend`、`--skip-tests` 做局部调试。
|
||||||
|
|
||||||
|
数据库 SQL 约束、执行计划、慢查询观测、连接池和备份恢复要求见 [`docs/数据库SQL约束与运维.md`](docs/数据库SQL约束与运维.md)。
|
||||||
|
|
||||||
需要在本地推送前自动执行同一套检查时,启用 Git hook:
|
需要在本地推送前自动执行同一套检查时,启用 Git hook:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ MYSQL_USER=hfb
|
|||||||
MYSQL_PASSWORD=secret
|
MYSQL_PASSWORD=secret
|
||||||
MYSQL_DSN=hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local
|
MYSQL_DSN=hfb:secret@tcp(127.0.0.1:13306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local
|
||||||
|
|
||||||
|
# 数据库连接池与慢查询观测;生产按实例数和 MySQL max_connections 调整。
|
||||||
|
DATABASE_MAX_OPEN_CONNS=50
|
||||||
|
DATABASE_MAX_IDLE_CONNS=10
|
||||||
|
DATABASE_CONN_MAX_LIFETIME_MINUTES=30
|
||||||
|
DATABASE_CONN_MAX_IDLE_TIME_MINUTES=5
|
||||||
|
DATABASE_SLOW_QUERY_THRESHOLD_MS=500
|
||||||
|
|
||||||
REDIS_ADDR=127.0.0.1:16379
|
REDIS_ADDR=127.0.0.1:16379
|
||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=
|
||||||
REDIS_DB=0
|
REDIS_DB=0
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ MYSQL_USER=hfb
|
|||||||
MYSQL_PASSWORD=change-hfb-password
|
MYSQL_PASSWORD=change-hfb-password
|
||||||
MYSQL_DSN=hfb:change-hfb-password@tcp(mysql:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local
|
MYSQL_DSN=hfb:change-hfb-password@tcp(mysql:3306)/hfb_sys?charset=utf8mb4&parseTime=True&loc=Local
|
||||||
|
|
||||||
|
# 数据库连接池与慢查询阈值;总连接数按实例数核算,避免超过 MySQL max_connections。
|
||||||
|
DATABASE_MAX_OPEN_CONNS=50
|
||||||
|
DATABASE_MAX_IDLE_CONNS=10
|
||||||
|
DATABASE_CONN_MAX_LIFETIME_MINUTES=30
|
||||||
|
DATABASE_CONN_MAX_IDLE_TIME_MINUTES=5
|
||||||
|
DATABASE_SLOW_QUERY_THRESHOLD_MS=500
|
||||||
|
|
||||||
REDIS_ADDR=redis:6379
|
REDIS_ADDR=redis:6379
|
||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=
|
||||||
REDIS_DB=0
|
REDIS_DB=0
|
||||||
|
|||||||
@@ -58,7 +58,13 @@ func main() {
|
|||||||
logAuthRuntimeIdentity(logger, cfg)
|
logAuthRuntimeIdentity(logger, cfg)
|
||||||
|
|
||||||
var deps router.Dependencies
|
var deps router.Dependencies
|
||||||
db, err := database.OpenMySQL(cfg.MySQLDSN, cfg.Log.Level, logger)
|
db, err := database.OpenMySQLWithOptions(cfg.MySQLDSN, cfg.Log.Level, logger, database.MySQLOptions{
|
||||||
|
MaxOpenConns: cfg.Database.MaxOpenConns,
|
||||||
|
MaxIdleConns: cfg.Database.MaxIdleConns,
|
||||||
|
ConnMaxLifetime: cfg.Database.ConnMaxLifetime,
|
||||||
|
ConnMaxIdleTime: cfg.Database.ConnMaxIdleTime,
|
||||||
|
SlowQueryThreshold: cfg.Database.SlowQueryThreshold,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("MySQL 不可用,数据库接口将返回 503", zap.Error(err))
|
logger.Warn("MySQL 不可用,数据库接口将返回 503", zap.Error(err))
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -29,6 +30,7 @@ type Config struct {
|
|||||||
Realname RealnameConfig
|
Realname RealnameConfig
|
||||||
Log LogConfig
|
Log LogConfig
|
||||||
RateLimit RateLimitConfig
|
RateLimit RateLimitConfig
|
||||||
|
Database DatabaseConfig
|
||||||
BackupStatusFile string
|
BackupStatusFile string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +72,14 @@ type RateLimitConfig struct {
|
|||||||
RequestsPerMinute int
|
RequestsPerMinute int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
MaxOpenConns int
|
||||||
|
MaxIdleConns int
|
||||||
|
ConnMaxLifetime time.Duration
|
||||||
|
ConnMaxIdleTime time.Duration
|
||||||
|
SlowQueryThreshold time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
func Load() Config {
|
func Load() Config {
|
||||||
return Config{
|
return Config{
|
||||||
AppEnv: getEnv("APP_ENV", "development"),
|
AppEnv: getEnv("APP_ENV", "development"),
|
||||||
@@ -131,6 +141,13 @@ func Load() Config {
|
|||||||
Enabled: getEnvBool("RATE_LIMIT_ENABLED", true),
|
Enabled: getEnvBool("RATE_LIMIT_ENABLED", true),
|
||||||
RequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 300),
|
RequestsPerMinute: getEnvInt("RATE_LIMIT_REQUESTS_PER_MINUTE", 300),
|
||||||
},
|
},
|
||||||
|
Database: DatabaseConfig{
|
||||||
|
MaxOpenConns: getEnvIntMin("DATABASE_MAX_OPEN_CONNS", 50, 1),
|
||||||
|
MaxIdleConns: getEnvIntMin("DATABASE_MAX_IDLE_CONNS", 10, 0),
|
||||||
|
ConnMaxLifetime: time.Duration(getEnvIntMin("DATABASE_CONN_MAX_LIFETIME_MINUTES", 30, 1)) * time.Minute,
|
||||||
|
ConnMaxIdleTime: time.Duration(getEnvIntMin("DATABASE_CONN_MAX_IDLE_TIME_MINUTES", 5, 0)) * time.Minute,
|
||||||
|
SlowQueryThreshold: time.Duration(getEnvIntMin("DATABASE_SLOW_QUERY_THRESHOLD_MS", 500, 1)) * time.Millisecond,
|
||||||
|
},
|
||||||
BackupStatusFile: getEnv("BACKUP_STATUS_FILE", "/var/run/hfb-backup-status/status.json"),
|
BackupStatusFile: getEnv("BACKUP_STATUS_FILE", "/var/run/hfb-backup-status/status.json"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,6 +225,14 @@ func getEnvInt(key string, fallback int) int {
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getEnvIntMin(key string, fallback, minimum int) int {
|
||||||
|
value := getEnvInt(key, fallback)
|
||||||
|
if value < minimum {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
func getEnvBool(key string, fallback bool) bool {
|
func getEnvBool(key string, fallback bool) bool {
|
||||||
value := os.Getenv(key)
|
value := os.Getenv(key)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
|
|||||||
@@ -26,6 +26,41 @@ func TestIsProductionEnv(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadDatabaseConfigFromEnvironment(t *testing.T) {
|
||||||
|
t.Setenv("DATABASE_MAX_OPEN_CONNS", "80")
|
||||||
|
t.Setenv("DATABASE_MAX_IDLE_CONNS", "16")
|
||||||
|
t.Setenv("DATABASE_CONN_MAX_LIFETIME_MINUTES", "45")
|
||||||
|
t.Setenv("DATABASE_CONN_MAX_IDLE_TIME_MINUTES", "7")
|
||||||
|
t.Setenv("DATABASE_SLOW_QUERY_THRESHOLD_MS", "750")
|
||||||
|
|
||||||
|
cfg := Load()
|
||||||
|
if cfg.Database.MaxOpenConns != 80 || cfg.Database.MaxIdleConns != 16 {
|
||||||
|
t.Fatalf("database pool = %+v, want open=80 idle=16", cfg.Database)
|
||||||
|
}
|
||||||
|
if cfg.Database.ConnMaxLifetime.Minutes() != 45 || cfg.Database.ConnMaxIdleTime.Minutes() != 7 {
|
||||||
|
t.Fatalf("database lifetimes = %s/%s, want 45m/7m", cfg.Database.ConnMaxLifetime, cfg.Database.ConnMaxIdleTime)
|
||||||
|
}
|
||||||
|
if cfg.Database.SlowQueryThreshold.Milliseconds() != 750 {
|
||||||
|
t.Fatalf("slow query threshold = %s, want 750ms", cfg.Database.SlowQueryThreshold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDatabaseConfigInvalidValuesUseDefaults(t *testing.T) {
|
||||||
|
t.Setenv("DATABASE_MAX_OPEN_CONNS", "0")
|
||||||
|
t.Setenv("DATABASE_MAX_IDLE_CONNS", "-1")
|
||||||
|
t.Setenv("DATABASE_CONN_MAX_LIFETIME_MINUTES", "0")
|
||||||
|
t.Setenv("DATABASE_CONN_MAX_IDLE_TIME_MINUTES", "-1")
|
||||||
|
t.Setenv("DATABASE_SLOW_QUERY_THRESHOLD_MS", "0")
|
||||||
|
|
||||||
|
cfg := Load()
|
||||||
|
if cfg.Database.MaxOpenConns != 50 || cfg.Database.MaxIdleConns != 10 {
|
||||||
|
t.Fatalf("invalid pool values = %+v, want defaults open=50 idle=10", cfg.Database)
|
||||||
|
}
|
||||||
|
if cfg.Database.ConnMaxLifetime.Minutes() != 30 || cfg.Database.ConnMaxIdleTime.Minutes() != 5 || cfg.Database.SlowQueryThreshold.Milliseconds() != 500 {
|
||||||
|
t.Fatalf("invalid database durations = %+v, want defaults", cfg.Database)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric 验证非生产环境未设置 legacy 时
|
// TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric 验证非生产环境未设置 legacy 时
|
||||||
// 回退到历史硬编码密钥(开发态零配置兼容旧密文)。
|
// 回退到历史硬编码密钥(开发态零配置兼容旧密文)。
|
||||||
func TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric(t *testing.T) {
|
func TestFieldEncryptionLegacyKeyNonProductionDefaultsToHistoric(t *testing.T) {
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ type structuredGormLogger struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface {
|
func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface {
|
||||||
|
return newGormLoggerWithThreshold(logLevel, logger, 500*time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGormLoggerWithThreshold(logLevel string, logger *zap.Logger, slowThreshold time.Duration) gormLogger.Interface {
|
||||||
level := gormLogger.Warn
|
level := gormLogger.Warn
|
||||||
if strings.EqualFold(strings.TrimSpace(logLevel), "debug") {
|
if strings.EqualFold(strings.TrimSpace(logLevel), "debug") {
|
||||||
level = gormLogger.Info
|
level = gormLogger.Info
|
||||||
@@ -28,10 +32,13 @@ func newGormLogger(logLevel string, logger *zap.Logger) gormLogger.Interface {
|
|||||||
if logger == nil {
|
if logger == nil {
|
||||||
logger = zap.L()
|
logger = zap.L()
|
||||||
}
|
}
|
||||||
|
if slowThreshold <= 0 {
|
||||||
|
slowThreshold = 500 * time.Millisecond
|
||||||
|
}
|
||||||
return &structuredGormLogger{
|
return &structuredGormLogger{
|
||||||
logger: logger.With(zap.String("module", "database")),
|
logger: logger.With(zap.String("module", "database")),
|
||||||
level: level,
|
level: level,
|
||||||
slowThreshold: 500 * time.Millisecond,
|
slowThreshold: slowThreshold,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,50 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type MySQLOptions struct {
|
||||||
|
MaxOpenConns int
|
||||||
|
MaxIdleConns int
|
||||||
|
ConnMaxLifetime time.Duration
|
||||||
|
ConnMaxIdleTime time.Duration
|
||||||
|
SlowQueryThreshold time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultMySQLOptions() MySQLOptions {
|
||||||
|
return MySQLOptions{
|
||||||
|
MaxOpenConns: 50,
|
||||||
|
MaxIdleConns: 10,
|
||||||
|
ConnMaxLifetime: 30 * time.Minute,
|
||||||
|
ConnMaxIdleTime: 5 * time.Minute,
|
||||||
|
SlowQueryThreshold: 500 * time.Millisecond,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, error) {
|
func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, error) {
|
||||||
|
return OpenMySQLWithOptions(dsn, logLevel, appLogger, DefaultMySQLOptions())
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenMySQLWithOptions(dsn string, logLevel string, appLogger *zap.Logger, options MySQLOptions) (*gorm.DB, error) {
|
||||||
|
defaults := DefaultMySQLOptions()
|
||||||
|
if options.MaxOpenConns < 1 {
|
||||||
|
options.MaxOpenConns = defaults.MaxOpenConns
|
||||||
|
}
|
||||||
|
if options.MaxIdleConns < 0 {
|
||||||
|
options.MaxIdleConns = defaults.MaxIdleConns
|
||||||
|
}
|
||||||
|
if options.ConnMaxLifetime <= 0 {
|
||||||
|
options.ConnMaxLifetime = defaults.ConnMaxLifetime
|
||||||
|
}
|
||||||
|
if options.ConnMaxIdleTime < 0 {
|
||||||
|
options.ConnMaxIdleTime = defaults.ConnMaxIdleTime
|
||||||
|
}
|
||||||
|
if options.SlowQueryThreshold <= 0 {
|
||||||
|
options.SlowQueryThreshold = defaults.SlowQueryThreshold
|
||||||
|
}
|
||||||
|
if options.MaxIdleConns > options.MaxOpenConns {
|
||||||
|
options.MaxIdleConns = options.MaxOpenConns
|
||||||
|
}
|
||||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||||
Logger: newGormLogger(logLevel, appLogger),
|
Logger: newGormLoggerWithThreshold(logLevel, appLogger, options.SlowQueryThreshold),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -20,11 +61,11 @@ func OpenMySQL(dsn string, logLevel string, appLogger *zap.Logger) (*gorm.DB, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// 限制连接池,避免本地压测瞬间打满 MySQL max_connections。
|
// 限制连接池,避免单个实例耗尽 MySQL max_connections。
|
||||||
sqlDB.SetMaxOpenConns(50)
|
sqlDB.SetMaxOpenConns(options.MaxOpenConns)
|
||||||
sqlDB.SetMaxIdleConns(10)
|
sqlDB.SetMaxIdleConns(options.MaxIdleConns)
|
||||||
sqlDB.SetConnMaxLifetime(30 * time.Minute)
|
sqlDB.SetConnMaxLifetime(options.ConnMaxLifetime)
|
||||||
sqlDB.SetConnMaxIdleTime(5 * time.Minute)
|
sqlDB.SetConnMaxIdleTime(options.ConnMaxIdleTime)
|
||||||
|
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ character-set-server = utf8mb4
|
|||||||
collation-server = utf8mb4_unicode_ci
|
collation-server = utf8mb4_unicode_ci
|
||||||
init-connect = 'SET NAMES utf8mb4'
|
init-connect = 'SET NAMES utf8mb4'
|
||||||
|
|
||||||
|
# 生产慢查询观测:应用日志记录 SQL 形状,MySQL 原生日志记录超过阈值的语句。
|
||||||
|
# performance_schema 默认开启,使用 events_statements_summary_by_digest 做聚合分析。
|
||||||
|
slow_query_log = ON
|
||||||
|
long_query_time = 0.5
|
||||||
|
log_slow_admin_statements = ON
|
||||||
|
|
||||||
# 时间点恢复基础:binlog 由 scripts/archive-binlog.sh 每分钟归档到 OSS 备份桶。
|
# 时间点恢复基础:binlog 由 scripts/archive-binlog.sh 每分钟归档到 OSS 备份桶。
|
||||||
# 本地按 7 天自动过期,归档与本地保留策略见 scripts/backup-online.sh。
|
# 本地按 7 天自动过期,归档与本地保留策略见 scripts/backup-online.sh。
|
||||||
server-id = 1
|
server-id = 1
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# 数据库 SQL 约束与运维
|
||||||
|
|
||||||
|
本项目使用 MySQL 8.4、GORM 和 Goose。本文是提交、上线和故障排查的数据库基线;SQL 静态检查不能替代真实数据量下的执行计划。
|
||||||
|
|
||||||
|
## 提交前约束
|
||||||
|
|
||||||
|
新增或修改 SQL、GORM 查询或迁移必须满足:
|
||||||
|
|
||||||
|
1. 列表查询必须有分页、最大数量或明确日期范围,禁止无界读取订单、流水、消息和日志历史。
|
||||||
|
2. `GROUP BY`、`COUNT(DISTINCT)`、窗口函数、跨表聚合、`UNION` 和相关子查询必须在接近生产数据量的 MySQL 上保存 `EXPLAIN FORMAT=JSON` 或 `EXPLAIN ANALYZE` 结果。
|
||||||
|
3. 高频查询先限定当前时间范围或当前页 ID,再做关联和聚合;禁止读取用户全部历史记录后在应用层过滤。
|
||||||
|
4. 新增索引必须通过新的 Goose migration 提交,并在迁移注释中说明覆盖的查询条件、排序和基数;大表索引要评估锁影响和上线窗口。
|
||||||
|
5. SQL 值必须使用 GORM 参数绑定或 `?` 占位符,禁止拼接请求参数。动态排序字段只能来自白名单。
|
||||||
|
6. 已应用的迁移文件禁止修改或删除,只能新增迁移完成结构修复;除 `000001_init.sql` 外的新迁移必须包含 `-- +goose Up` 和 `-- +goose Down`。
|
||||||
|
7. 角色、权限、推送渠道、客服组等小型配置表允许全量读取,但必须保持数量边界或在本文件的白名单中登记;业务数据不得套用该例外。
|
||||||
|
|
||||||
|
本地 `scripts/check.sh` 会执行 `scripts/check-sql-guard.sh`,保护关键索引、时间范围和迁移完整性。它只防止关键代码被重构移除,不判断执行计划是否优秀。
|
||||||
|
|
||||||
|
## 执行计划
|
||||||
|
|
||||||
|
在维护库或只读副本上替换真实参数后执行:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
EXPLAIN FORMAT=JSON
|
||||||
|
SELECT ...;
|
||||||
|
|
||||||
|
EXPLAIN ANALYZE
|
||||||
|
SELECT ...;
|
||||||
|
```
|
||||||
|
|
||||||
|
重点检查扫描行数、实际返回行数、索引使用、临时表、文件排序和 join 顺序。财务首页的仪表盘、结算明细和提现合并列表属于必须定期复核的查询。
|
||||||
|
|
||||||
|
## 生产观测
|
||||||
|
|
||||||
|
生产 MySQL 应保持 `performance_schema` 开启,并启用慢查询日志。项目的 `deploy/mysql/my.cnf` 默认记录超过 500ms 的语句;应用日志只记录 SQL 形状、耗时和返回行数,不记录绑定参数值。
|
||||||
|
|
||||||
|
常用排查 SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
DIGEST_TEXT,
|
||||||
|
COUNT_STAR,
|
||||||
|
ROUND(SUM_TIMER_WAIT / 1000000000000, 2) AS total_seconds,
|
||||||
|
ROUND(AVG_TIMER_WAIT / 1000000000, 2) AS avg_ms,
|
||||||
|
SUM_ROWS_SENT,
|
||||||
|
SUM_ROWS_EXAMINED
|
||||||
|
FROM performance_schema.events_statements_summary_by_digest
|
||||||
|
ORDER BY SUM_TIMER_WAIT DESC
|
||||||
|
LIMIT 30;
|
||||||
|
|
||||||
|
SELECT THREAD_ID, EVENT_NAME, TIMER_WAIT, SQL_TEXT, ROWS_EXAMINED
|
||||||
|
FROM performance_schema.events_statements_current
|
||||||
|
WHERE SQL_TEXT IS NOT NULL
|
||||||
|
ORDER BY TIMER_WAIT DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
应用慢查询阈值由 `DATABASE_SLOW_QUERY_THRESHOLD_MS` 配置。阈值调整后要同步检查 MySQL `long_query_time`,避免应用和数据库观测口径长期不一致。
|
||||||
|
|
||||||
|
## 连接、超时与容量
|
||||||
|
|
||||||
|
- `DATABASE_MAX_OPEN_CONNS` 是单个 backend 实例的连接上限,总量必须按实例数核算,不能简单随实例扩容同比增加。
|
||||||
|
- `DATABASE_MAX_IDLE_CONNS`、`DATABASE_CONN_MAX_LIFETIME_MINUTES` 和 `DATABASE_CONN_MAX_IDLE_TIME_MINUTES` 控制空闲连接和连接轮换。
|
||||||
|
- 请求必须设置 Go `context` 截止时间;锁等待由 MySQL `innodb_lock_wait_timeout` 控制。长报表查询应进入异步任务或汇总表。
|
||||||
|
- 数据量增长后,排行榜、财务历史统计和审计报表应迁移到日汇总表、缓存或只读副本,不能持续依赖在线全表聚合。
|
||||||
|
- 修改连接池或容器资源后,用 `docker compose config` 核对最终插值;资源限额应高于稳定 RSS 峰值并留出数据库连接和备份余量。
|
||||||
|
|
||||||
|
## 备份与恢复
|
||||||
|
|
||||||
|
生产使用 XtraBackup 全量备份加 MySQL ROW binlog 归档实现时间点恢复。备份对象先客户端加密,再使用独立 OSS bucket 的服务端加密;恢复必须在隔离目录和隔离 MySQL 容器演练,禁止直接向生产实例回放。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/backup-online.sh status
|
||||||
|
./scripts/archive-binlog.sh verify
|
||||||
|
./scripts/restore-online.sh prepare <complete.env-OSS-URI>
|
||||||
|
```
|
||||||
|
|
||||||
|
备份完成标识、SHA-256、manifest、server UUID 和 Goose 版本必须一致;恢复演练至少按月验证一次,并记录恢复时间点和结果。
|
||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 检查关键 SQL 防线是否仍存在;真实数据量下的执行计划需要在 MySQL 上单独验证。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
MIGRATIONS_DIR="${ROOT_DIR}/backend/migrations"
|
||||||
|
failures=()
|
||||||
|
|
||||||
|
require_pattern() {
|
||||||
|
local file="$1" pattern="$2" name="$3"
|
||||||
|
if [[ ! -f "${ROOT_DIR}/${file}" ]]; then
|
||||||
|
failures+=("${name}: 文件不存在 ${file}")
|
||||||
|
elif ! grep -Fq -- "${pattern}" "${ROOT_DIR}/${file}"; then
|
||||||
|
failures+=("${name}: 缺少 ${pattern} (${file})")
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 数据库运行时配置和参数脱敏不能被重构删除。
|
||||||
|
require_pattern "backend/internal/config/config.go" "DATABASE_SLOW_QUERY_THRESHOLD_MS" "慢查询阈值配置"
|
||||||
|
require_pattern "backend/internal/config/config.go" "DATABASE_MAX_OPEN_CONNS" "连接池上限配置"
|
||||||
|
require_pattern "backend/internal/database/gorm_logger.go" "ParamsFilter" "SQL 参数脱敏"
|
||||||
|
require_pattern "backend/internal/database/mysql.go" "SetMaxOpenConns" "连接池应用"
|
||||||
|
|
||||||
|
# 当前财务仪表盘的时间范围聚合和索引是高频查询防线。
|
||||||
|
require_pattern "backend/internal/modules/adminfinance/subquery.go" "settled_at >= ? AND scoped_order.settled_at <= ?" "结算聚合时间范围"
|
||||||
|
require_pattern "backend/internal/modules/adminfinance/dashboard.go" "po.created_at >= ? AND po.created_at <= ?" "支付聚合时间范围"
|
||||||
|
require_pattern "backend/migrations/000060_finance_dashboard_indexes.sql" "idx_admin_pickups_status_completed_at" "财务完成索引"
|
||||||
|
require_pattern "backend/migrations/000060_finance_dashboard_indexes.sql" "idx_admin_pickups_status_cancelled_at" "财务取消索引"
|
||||||
|
|
||||||
|
# 分页核心路径必须保留上限;配置型全量列表在运维文档中登记为白名单。
|
||||||
|
require_pattern "backend/internal/modules/listing/public_query.go" "if pageSize > 50" "公开商品分页上限"
|
||||||
|
require_pattern "backend/internal/modules/pickup/repository.go" "if pageSize > 100" "后台接单分页上限"
|
||||||
|
|
||||||
|
# 已发布迁移只允许新增,不允许修改或删除历史文件。
|
||||||
|
if changed_migrations="$(git -C "${ROOT_DIR}" diff --name-status HEAD -- backend/migrations)"; then
|
||||||
|
while read -r status file _; do
|
||||||
|
[[ -z "${status:-}" ]] && continue
|
||||||
|
if [[ "${status}" != "A" ]]; then
|
||||||
|
failures+=("历史迁移文件不可修改或删除: ${status} ${file}")
|
||||||
|
fi
|
||||||
|
done <<< "${changed_migrations}"
|
||||||
|
else
|
||||||
|
failures+=("无法检查迁移文件变更")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 初始合并迁移没有 Down,其余迁移必须提供可回滚段。
|
||||||
|
while IFS= read -r file; do
|
||||||
|
[[ "$(basename "${file}")" == "000001_init.sql" ]] && continue
|
||||||
|
if ! grep -Fq -- '-- +goose Up' "${file}" || ! grep -Fq -- '-- +goose Down' "${file}"; then
|
||||||
|
failures+=("迁移缺少 Goose Up/Down: ${file#"${ROOT_DIR}/"}")
|
||||||
|
fi
|
||||||
|
done < <(find "${MIGRATIONS_DIR}" -maxdepth 1 -type f -name '*.sql' | sort)
|
||||||
|
|
||||||
|
if (( ${#failures[@]} > 0 )); then
|
||||||
|
printf '[sql-guard] SQL 约束检查失败\n' >&2
|
||||||
|
printf '[sql-guard] %s\n' "${failures[@]}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '[sql-guard] SQL 约束检查通过\n'
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
# - 手动校验:开发时手动跑 ./scripts/check.sh
|
# - 手动校验:开发时手动跑 ./scripts/check.sh
|
||||||
#
|
#
|
||||||
# 包含:
|
# 包含:
|
||||||
|
# 数据库:SQL 约束与迁移完整性检查
|
||||||
# 后端:go vet + go build + go test
|
# 后端:go vet + go build + go test
|
||||||
# 前端:npm run check (prettier + eslint + typecheck) + npm run test + npm run build
|
# 前端:npm run check (prettier + eslint + typecheck) + npm run test + npm run build
|
||||||
#
|
#
|
||||||
@@ -91,6 +92,7 @@ if [[ "${SKIP_BACKEND}" == "0" ]]; then
|
|||||||
log "后端检查目录: ${BACKEND_DIR}"
|
log "后端检查目录: ${BACKEND_DIR}"
|
||||||
cd "${BACKEND_DIR}"
|
cd "${BACKEND_DIR}"
|
||||||
|
|
||||||
|
run_step "SQL 约束检查" bash "${ROOT_DIR}/scripts/check-sql-guard.sh" || FAILED=1
|
||||||
run_step "go vet ./..." go vet ./... || FAILED=1
|
run_step "go vet ./..." go vet ./... || FAILED=1
|
||||||
run_step "go build ./..." go build ./... || FAILED=1
|
run_step "go build ./..." go build ./... || FAILED=1
|
||||||
if [[ "${SKIP_TESTS}" == "0" ]]; then
|
if [[ "${SKIP_TESTS}" == "0" ]]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user