第一章:Go数据库连接池幻灭的真相与认知重构
许多Go开发者初识database/sql时,常误将sql.DB视为一个“数据库连接”,进而认为调用db.Query()会即时建立新连接、执行完毕即释放——这种直觉在高并发场景下迅速崩塌。真相是:sql.DB本身就是一个内置连接池管理器,它不实现连接协议,只负责复用、创建、销毁和健康检查底层连接。所谓“连接池幻灭”,正是源于对SetMaxOpenConns、SetMaxIdleConns、SetConnMaxLifetime三者协同机制的长期误解。
连接池行为的三大幻觉
-
幻觉一:“MaxOpenConns限制并发数”
实际上它限制的是池中同时存在的最大连接数(含忙/闲);超出此值的请求将阻塞,而非失败(除非设置了SetConnMaxIdleTime并超时)。 -
幻觉二:“空闲连接永不复用”
SetMaxIdleConns仅控制空闲连接上限;若设为0,每次查询都可能新建连接再立即关闭,引发TIME_WAIT风暴。 -
幻觉三:“连接永远可用”
数据库服务重启或网络闪断后,池中 stale 连接不会自动剔除——需依赖SetConnMaxLifetime(强制定期重建)与db.PingContext()主动探测。
验证连接池状态的实用方法
可通过db.Stats()获取实时指标,例如:
stats := db.Stats()
fmt.Printf("Open connections: %d\n", stats.OpenConnections) // 当前已打开连接数
fmt.Printf("Idle connections: %d\n", stats.Idle) // 当前空闲连接数
fmt.Printf("Wait count: %d\n", stats.WaitCount) // 等待连接的总次数(反映阻塞压力)
推荐的初始化配置组合
| 参数 | 推荐值 | 说明 |
|---|---|---|
SetMaxOpenConns(20) |
≤ 应用实例数 × 数据库单节点连接上限 | 避免DB侧资源耗尽 |
SetMaxIdleConns(10) |
≈ MaxOpenConns / 2 |
平衡复用率与内存占用 |
SetConnMaxLifetime(30 * time.Minute) |
≥ 数据库wait_timeout |
主动轮换防僵死 |
SetConnMaxIdleTime(5 * time.Minute) |
ConnMaxLifetime | 及时清理长期空闲连接 |
务必在sql.Open()后立即调用db.Ping()验证基础连通性,并在服务启动日志中打印db.Stats()初始快照——这是观测连接池是否按预期加载的第一手证据。
第二章:SetMaxOpenConns=0的隐藏行为深度解构
2.1 连接池容量语义歧义:源码级解读sql.DB中maxOpen的零值逻辑
sql.DB 的 MaxOpenConns 字段常被误读为“最小连接数”或“禁用限制”,实则其零值具有明确且关键的语义:
表示无硬性上限(不限制最大打开连接数)- 负值非法,运行时 panic
- 非零正整数才启用连接数截断逻辑
// src/database/sql/sql.go 中相关逻辑节选
func (db *DB) maxOpenConns() int {
db.mu.Lock()
defer db.mu.Unlock()
if db.maxOpen == 0 {
return 0 // 零值 → 不施加上限约束
}
return db.maxOpen
}
该函数返回 时,connMaxLifetimeTimer 等资源管控路径将跳过连接驱逐检查,导致连接数随并发请求线性增长直至系统资源耗尽。
| maxOpen 值 | 行为语义 | 是否触发连接数截断 |
|---|---|---|
| 0 | 无上限,完全依赖 OS 和内核限制 | 否 |
| 10 | 最多保持 10 个活跃连接 | 是 |
| -1 | 初始化失败 panic | — |
graph TD
A[SetMaxOpenConnsn0] --> B{db.maxOpen == 0?}
B -->|Yes| C[openNewConnection 无阻塞]
B -->|No| D[check maxOpen before open]
2.2 实验验证:压测对比SetMaxOpenConns=0 vs =1 vs =n下的并发连接爆炸曲线
为量化连接池配置对数据库连接数的实际影响,我们在相同负载(500 QPS,平均响应时间
SetMaxOpenConns=0:无上限,连接数随并发请求线性飙升SetMaxOpenConns=1:强制串行化,高延迟但连接数恒为1SetMaxOpenConns=n(n=20):典型生产配置,连接数收敛于阈值附近
压测结果(峰值连接数 / 稳定期平均延迟)
| 配置 | 峰值连接数 | 平均延迟(ms) | 连接复用率 |
|---|---|---|---|
=0 |
487 | 126 | 32% |
=1 |
1 | 892 | 100% |
=20 |
20 | 43 | 89% |
db.SetMaxOpenConns(20) // 允许最多20个活跃连接
db.SetMaxIdleConns(10) // 空闲连接池上限,避免资源闲置
db.SetConnMaxLifetime(30 * time.Minute) // 防止长连接老化
此配置使连接复用率提升至89%,延迟降低76%;
SetMaxOpenConns=0虽不报错,但实际触发内核级文件描述符耗尽风险。
连接生命周期示意
graph TD
A[请求到达] --> B{连接池有空闲连接?}
B -- 是 --> C[复用连接]
B -- 否 --> D[创建新连接]
D --> E{已达MaxOpenConns?}
E -- 是 --> F[阻塞等待或超时]
E -- 否 --> C
2.3 应用层误用模式分析:ORM封装、微服务网关、健康检查探针中的典型陷阱
ORM 封装过度导致 N+1 查询
常见于将 @OneToMany 关系默认设为 FetchType.EAGER:
@Entity
public class Order {
@Id Long id;
@OneToMany(fetch = FetchType.EAGER) // ❌ 触发隐式全量加载
List<OrderItem> items;
}
逻辑分析:EAGER 强制每次查订单都拉取全部子项,即使业务仅需 ID 或状态;应改用 LAZY + @EntityGraph 按需显式加载。
微服务网关健康检查误配
下表对比常见探针策略风险:
| 探针类型 | 检查路径 | 风险点 |
|---|---|---|
| Liveness | /actuator/health/liveness |
依赖数据库连接 → 级联故障 |
| Readiness | /actuator/health/readiness |
未排除缓存组件 → 假阳性 |
健康检查与业务逻辑耦合
graph TD
A[GET /health] --> B{调用DB连接池}
B --> C[执行 SELECT 1]
C --> D[触发慢查询熔断]
D --> E[网关判定服务宕机]
2.4 连接复用率与GC压力实测:pprof火焰图揭示goroutine阻塞与内存泄漏耦合现象
在高并发HTTP服务中,连接池复用率下降常伴随GC Pause陡增。我们通过 go tool pprof -http=:8080 cpu.pprof 启动可视化分析,发现火焰图中 net/http.(*conn).serve 持续堆叠,且底层调用链高频出现 runtime.gcStart 与 sync.runtime_SemacquireMutex 交织。
pprof关键指标对比
| 指标 | 正常态(复用率>95%) | 异常态(复用率 |
|---|---|---|
| GC pause 99%ile | 120μs | 4.7ms |
| goroutine 阻塞中位数 | 8ms | 320ms |
内存泄漏耦合路径(mermaid)
graph TD
A[HTTP Handler] --> B[未关闭的io.ReadCloser]
B --> C[ResponseWriter未Flush]
C --> D[goroutine stuck in writeLoop]
D --> E[buf[] 持久驻留堆上]
E --> F[触发高频GC扫描]
典型阻塞代码片段
func handleUpload(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close() // ✅ 必须显式关闭
data, _ := io.ReadAll(r.Body) // ❌ 若Body超大且未限流,data切片长期存活
// ... 处理逻辑
w.Write(data) // 若未设置Content-Length或未Flush,conn可能无法归还连接池
}
io.ReadAll 返回的 []byte 直接逃逸至堆,若后续无显式零化或及时释放,将延长对象生命周期,加剧GC扫描压力;同时因连接未及时归还,新请求被迫新建连接,形成“复用率↓ → 连接数↑ → 内存↑ → GC↑ → 响应延迟↑”正反馈闭环。
2.5 安全兜底方案:运行时动态校验+Prometheus指标熔断机制设计与落地
当核心服务面临突发流量或依赖异常时,静态配置的限流策略常显僵化。我们引入双模兜底:运行时动态校验保障业务逻辑安全,Prometheus驱动的熔断器实现可观测性闭环。
数据同步机制
校验逻辑嵌入请求处理链路末尾,通过 @PostAuthorize 注解触发:
@PostAuthorize("hasPermission(#result, 'WRITE') && T(com.example.SafetyGuard).validateRuntime(#result)")
public Order createOrder(@Valid Order order) {
return orderService.save(order);
}
逻辑分析:
#result是方法返回值;hasPermission()检查RBAC权限;SafetyGuard.validateRuntime()执行实时风控规则(如金额阈值、IP频次、敏感字段脱敏状态),所有校验失败抛出AccessDeniedException,由全局异常处理器统一降级为403或503。
熔断决策流程
基于 Prometheus 的 http_server_requests_seconds_count{status=~"5..", uri="/api/order"} 指标,每30秒计算错误率:
| 指标名称 | 阈值 | 触发动作 |
|---|---|---|
error_rate_1m |
> 35% | 进入半开状态 |
request_volume_1m |
自动重置熔断器 |
graph TD
A[HTTP 请求] --> B{是否命中熔断?}
B -- 是 --> C[返回 503 + fallback]
B -- 否 --> D[执行业务逻辑]
D --> E[上报 Prometheus 指标]
E --> F[熔断器定时评估]
F --> B
第三章:driver.ErrBadConn重试逻辑缺陷剖析
3.1 标准库重试边界失效:从database/sql到driver.Driver接口的错误传播断层
database/sql 的 DB.QueryRow() 等方法看似支持重试,实则在驱动层(driver.Driver.Open → driver.Conn → driver.Stmt.Exec)完全不透出可重试语义。错误一旦由底层驱动(如 pq、mysql)以 *driver.ErrBadConn 或自定义 error 返回,sql.DB 仅作连接回收,不触发重试逻辑。
错误传播断层示意
// driver.Conn.Exec 实现片段(以 pq 驱动为例)
func (s *stmt) Exec(args []driver.Value) (driver.Result, error) {
// 网络超时或连接中断时,直接返回 error,无重试钩子
if err := s.c.sendQuery(...); err != nil {
return nil, err // ← 此 error 被 sql.DB 视为“不可恢复”,跳过重试
}
// ...
}
该 error 被 sql.connStmt.exec() 捕获后,仅调用 c.resetSession() 并归还连接池,不检查是否可重试(如 IsTimeout()、IsNetworkError())。
关键断层对比
| 组件 | 是否参与重试决策 | 原因 |
|---|---|---|
sql.DB |
❌ 否 | 仅依据 driver.ErrBadConn 判断连接有效性,不解析 error 类型 |
driver.Conn |
❌ 否 | 接口无 Retryable() bool 方法,error 语义未标准化 |
| 应用层 | ✅ 是 | 必须手动包装 sql.ErrNoRows/网络 error 并实现重试 |
graph TD
A[sql.DB.QueryRow] --> B[sql.connStmt.exec]
B --> C[driver.Conn.Exec]
C --> D[底层TCP write timeout]
D --> E[return error]
E --> F{sql.DB inspect error?}
F -->|仅看是否==driver.ErrBadConn| G[Close & recycle conn]
F -->|忽略io.EOF/io.Timeout| H[向上panic/失败]
3.2 真实故障复现:网络闪断、TLS握手超时、MySQL 8.0 auth_plugin切换引发的无限重试链
故障链触发条件
当客户端(如 Spring Boot 3.1 + mysql-connector-j 8.3.0)连接启用了 caching_sha2_password 的 MySQL 8.0 实例,且中间网络出现 ClientHello → ServerHello 阶段超时(默认 connectTimeout=3000ms),触发驱动层自动重试。
关键重试逻辑(简化版)
// mysql-connector-j 8.3.0 中 AbstractProtocol.connect() 片段
if (this.tlsSocketFactory != null && !isTlsEstablished()) {
throw new CommunicationsException("TLS handshake timed out", cause);
// → 触发 ConnectionImpl.reconnect() → 无限循环:未校验 auth_plugin 是否已变更
}
逻辑分析:驱动在重连时未重新读取 authentication_plugin 响应字段,仍按上次协商的插件(如 caching_sha2_password)构造认证包;若服务端已切为 mysql_native_password,则返回 ER_HANDSHAKE_ERROR,驱动误判为网络异常,再次重试——形成闭环。
故障传播路径
graph TD
A[客户端发起连接] --> B{TLS握手超时}
B -->|是| C[驱动触发reconnect]
C --> D[复用旧auth_plugin]
D --> E[服务端拒绝认证]
E --> F[抛CommunicationsException]
F --> C
缓解措施对比
| 方案 | 是否中断重试链 | 配置示例 | 备注 |
|---|---|---|---|
allowPublicKeyRetrieval=true |
否 | JDBC URL 添加参数 | 仅缓解 SHA2 认证阻塞,不解决重试逻辑缺陷 |
connectTimeout=10000 |
否 | 延长超时 | 加剧雪崩风险 |
useSSL=false |
是 | 禁用 TLS | 开发环境可用,生产不推荐 |
3.3 替代性重试策略:基于exponential backoff + context deadline感知的中间件封装实践
传统固定间隔重试易加剧服务雪崩。我们封装了一个轻量中间件,自动融合指数退避与上下文截止时间动态裁剪。
核心设计原则
- 退避时长随失败次数指数增长(
base × 2^n) - 每次重试前检查
ctx.Deadline(),跳过超时不可达的尝试 - 支持自定义 jitter 防止重试风暴
Go 中间件实现
func WithExponentialBackoff(base time.Duration, maxRetries int) Middleware {
return func(next Handler) Handler {
return func(ctx context.Context, req any) (any, error) {
var err error
for i := 0; i <= maxRetries; i++ {
select {
case <-ctx.Done():
return nil, ctx.Err() // 尊重父上下文终止
default:
}
if resp, e := next(ctx, req); e == nil {
return resp, nil
} else {
err = e
}
if i < maxRetries {
d := time.Duration(float64(base) * math.Pow(2, float64(i)))
jitter := time.Duration(rand.Int63n(int64(d / 4)))
sleep := d + jitter
select {
case <-time.After(sleep):
case <-ctx.Done():
return nil, ctx.Err()
}
}
}
return nil, err
}
}
}
逻辑分析:
base控制初始退避基线(推荐 100ms),maxRetries限制总尝试次数(建议 ≤5)。每次退避引入±25% jitter抑制同步重试;select双通道等待确保不忽略ctx.Done()。
退避时序对比(单位:ms)
| 尝试次数 | 固定间隔 | 指数退避(base=100) | + jitter(±25%) |
|---|---|---|---|
| 1 | 100 | 100 | 82–124 |
| 2 | 100 | 200 | 157–241 |
| 3 | 100 | 400 | 312–489 |
graph TD
A[请求进入] --> B{是否成功?}
B -- 否 --> C[计算下次退避时长]
C --> D{距deadline剩余时间 ≥ 退避时长?}
D -- 是 --> E[time.After 休眠]
D -- 否 --> F[立即返回 ctx.Err]
E --> B
B -- 是 --> G[返回响应]
第四章:context取消未透传导致的3大连接泄漏场景
4.1 场景一:QueryContext后Cancel未触发conn.Close——底层net.Conn生命周期脱离控制流
当 QueryContext 返回后调用 cancel(),若底层 *sql.Conn 未及时释放,net.Conn 可能持续存活,游离于 context 生命周期之外。
根本原因
database/sql的连接复用机制与context取消信号解耦;QueryContext仅中断查询执行,不强制关闭物理连接。
典型复现代码
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
rows, err := db.QueryContext(ctx, "SELECT SLEEP(1)")
cancel() // 此时上下文已取消
// ⚠️ 但底层 net.Conn 可能仍在连接池中待复用
逻辑分析:
cancel()仅通知 driver 中断当前操作(如设置mysql.SetReadDeadline),但net.Conn的Close()调用由连接池回收逻辑触发,与 context 无直接绑定。参数ctx仅影响 query 阶段阻塞,不接管连接终态。
连接状态对照表
| 状态项 | QueryContext取消后 | conn.Close()显式调用后 |
|---|---|---|
net.Conn.Read |
可能阻塞或返回timeout | 立即返回 io.EOF |
| 连接池持有 | ✅ 仍可能复用 | ❌ 彻底释放 |
graph TD
A[QueryContext] -->|cancel()| B[中断查询执行]
B --> C[driver 设置 deadline]
C --> D[连接保留在 pool 中]
D --> E[下次 GetConn 可能复用该 net.Conn]
4.2 场景二:Tx.BeginTx后context提前取消,但driver.Tx未实现RollbackOnCancel契约
当 Tx.BeginTx(ctx, opts) 返回的 driver.Tx 未实现 driver.RollbackOnCancel 接口时,context.Canceled 不会触发自动回滚。
核心问题表现
sql.DB在ctx.Done()后无法安全终止事务;- 底层连接可能长期持有锁,引发阻塞或死锁。
典型调用链
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
tx, err := db.BeginTx(ctx, nil) // ctx 可能已取消,但 tx 无感知
if err != nil {
return err // 此处 err 可能为 nil(tx 已创建但未绑定 cancel)
}
// 后续 tx.Query/Exec 将阻塞或 panic
逻辑分析:
BeginTx内部仅检查ctx.Err()是否已触发,若 driver.Tx 未实现RollbackOnCancel,则sql.tx.rollbackCtx无法注册监听,导致 cancel 信号丢失。参数ctx失去生命周期控制力。
驱动兼容性对比
| 驱动类型 | 实现 RollbackOnCancel | Cancel 后自动回滚 | 建议 |
|---|---|---|---|
| pq (v1.10+) | ✅ | 是 | 生产推荐 |
| mysql-go-sql-driver | ❌ | 否 | 需手动 defer tx.Rollback() |
graph TD
A[BeginTx(ctx)] --> B{driver.Tx implements RollbackOnCancel?}
B -->|Yes| C[注册 ctx.Done() 监听 → 自动 Rollback]
B -->|No| D[ctx.Cancel 无效 → 事务悬停]
4.3 场景三:PrepareContext返回*Stmt时,stmt.QueryContext被cancel却未释放底层prepared statement资源
核心问题现象
当 PrepareContext 返回 *Stmt 后,若调用 stmt.QueryContext(ctx, args...) 且 ctx 被 cancel,Go 标准库仅中断查询执行,但不自动调用 stmt.Close(),导致数据库端 prepared statement 持续占用连接资源(如 MySQL 的 COM_STMT_PREPARE 对应句柄未销毁)。
资源泄漏路径
stmt, _ := db.PrepareContext(ctx, "SELECT ?")
// ctx.Cancel() 发生在 QueryContext 执行中 → 查询中断
_, _ = stmt.QueryContext(ctx, 42) // ✅ 查询失败,❌ stmt 未 Close()
// 此时底层 PREPARE 语句仍驻留服务端
逻辑分析:
QueryContext内部仅处理context.Done()中断链路,*Stmt生命周期与context解耦;stmt.Close()需显式调用或依赖 GC 触发finalizer(延迟不可控)。
关键参数说明
| 参数 | 作用 | 风险点 |
|---|---|---|
ctx in QueryContext |
控制单次查询超时/取消 | 不影响 *Stmt 资源生命周期 |
stmt 实例 |
封装预编译句柄及连接绑定 | 必须手动 Close() 或 defer |
正确实践模式
- ✅
defer stmt.Close()在 Prepare 后立即声明 - ✅ 使用
sql.Stmt.Close()显式释放(非db.Close()) - ❌ 依赖 context cancel 自动清理
graph TD
A[PrepareContext] --> B[返回*Stmt]
B --> C[QueryContext with canceled ctx]
C --> D[查询中断]
D --> E[底层PREPARE句柄残留]
B --> F[显式stmt.Close()]
F --> G[服务端句柄释放]
4.4 检测与修复工具链:go-sqlmock增强版、自研sqltrace hook + connection leak detector集成方案
为应对复杂微服务场景下的SQL可观测性与连接资源治理难题,我们构建了三层协同的检测与修复工具链。
核心组件职责分工
- go-sqlmock 增强版:支持
QueryContext/ExecContext模拟、参数绑定断言、动态延迟注入 - sqltrace hook:轻量级
driver.Connector包装器,自动注入 span ID 与执行耗时埋点 - connection leak detector:基于
sql.DB.Stats()定期采样 +runtime.SetFinalizer双路检测未关闭连接
集成调用流程
graph TD
A[应用发起 db.Query] --> B[sqltrace hook 拦截]
B --> C[记录 start time & context]
C --> D[委托原 driver 执行]
D --> E[hook 捕获 error/rows]
E --> F[上报 trace & 检查 Conn 状态]
F --> G[leak detector 异步告警]
关键代码片段(初始化)
// 初始化增强 mock + trace + leak 检测
db, _ := sql.Open("sqlmock", "test")
mock := sqlmock.NewWithConfig(sqlmock.Config{
Strict: true,
QueryMatcher: sqlmock.QueryMatcherEqual, // 精确匹配 SQL 字符串
})
db.SetConnMaxLifetime(0) // 禁用连接复用,确保 leak detector 精准触发
sqltrace.Register("sqlmock", mock.Driver(), sqltrace.WithTag("service", "auth"))
leakdetector.Start(db, 5*time.Second) // 每5秒扫描一次泄漏
此段代码完成三组件注册:
sqlmock.Config启用严格模式保障测试可靠性;sqltrace.Register绑定驱动并注入业务标签;leakdetector.Start启动周期性扫描,ConnMaxLifetime=0强制每次新建连接,使泄漏路径可复现。
第五章:通往稳健数据访问层的终局思考
构建一个真正稳健的数据访问层,从来不是堆砌ORM框架或简单封装JDBC连接池就能达成的目标。它是在真实业务洪流中反复淬炼出的工程共识——当订单服务每秒突增3000次库存扣减请求、当用户中心遭遇跨机房网络分区、当审计日志表因未加归档策略在三个月内膨胀至42亿行时,数据访问层的韧性才真正接受审判。
连接泄漏的现场复盘
某金融客户生产环境曾出现数据库连接耗尽告警。通过Arthas动态追踪发现:MyBatis SqlSession 在异常分支中未被显式关闭,而Spring事务代理的@Transactional在RuntimeException外抛出SQLException时未能触发回滚与资源释放。最终修复方案是强制启用defaultExecutorType=BATCH并配合try-with-resources包裹手动获取的SqlSession,同时在Druid监控面板配置连接存活时间阈值告警(>180s自动标记为可疑)。
分库分表后的查询一致性陷阱
电商系统将订单表按user_id % 64分片后,运营后台需统计“近7天高价值用户订单数”。原始SQL使用GROUP BY user_id HAVING SUM(amount) > 5000,但因分片键与聚合维度不一致,导致结果漏计。解决方案采用两阶段聚合:先在各分片执行SELECT user_id, SUM(amount) FROM order_XX WHERE create_time > ? GROUP BY user_id,再由应用层合并后二次过滤。ShardingSphere-Proxy v5.3.2 的broadcast-table机制在此场景下反而引入冗余扫描,故改用客户端分片路由+内存聚合。
| 场景 | 原始方案缺陷 | 稳健方案 | 验证方式 |
|---|---|---|---|
| 大字段读取 | SELECT * FROM article 导致网络传输超时 |
按业务域拆分article_summary与article_content两张表,使用延迟加载 |
JMeter压测TP99从2.1s降至187ms |
| 跨库事务 | Seata AT模式在MySQL 8.0.33+出现XA锁等待超时 | 改为Saga模式,订单创建成功后发MQ触发积分更新,失败时补偿回调 | 生产灰度期间补偿成功率99.998% |
flowchart LR
A[应用发起查询] --> B{是否命中缓存?}
B -->|是| C[返回缓存数据]
B -->|否| D[生成分片路由规则]
D --> E[并发查询多个物理库]
E --> F[结果归并与去重]
F --> G[写入本地Caffeine缓存]
G --> H[异步刷新Redis分布式缓存]
H --> C
写扩散引发的雪崩防控
用户标签系统采用user_id + tag_code作为主键,但运营活动期间单个用户被批量打标300+次,触发MySQL二级索引页分裂。优化后引入写缓冲队列:所有打标请求先进入Disruptor环形队列,消费者线程每200ms批量合并相同user_id的变更,生成INSERT ... ON DUPLICATE KEY UPDATE语句。监控显示InnoDB Buffer Pool的pages_read下降63%,磁盘IOPS峰值从12000稳定在2100以下。
数据版本化落地细节
商品中心要求保留每次价格变更的完整快照。放弃全量历史表方案,改用price_history表存储差异字段:id, sku_id, price_old, price_new, operator_id, create_time。关键约束在于CREATE UNIQUE INDEX uk_sku_time ON price_history(sku_id, create_time)配合应用层插入前校验——若检测到同一SKU在10分钟内已有变更,则合并为单条记录并更新price_old为最早值、price_new为最新值。
当凌晨三点收到数据库慢查询告警,真正决定系统生死的,永远是那个在连接池配置里多写的testWhileIdle=true,是那个在Mapper XML中坚持手写<if test="status != null">AND status = #{status}</if>而非盲目使用WHERE 1=1,是那个在每次上线前必跑的pt-query-digest --review h=localhost,D=prod,t=query_review自动化巡检脚本。
