小Cの已经记不起来的博客

微服务架构下 连接池 的设计与权衡

一开始我以为只是把 10 改成 200

上周订单服务突然开始报错,凌晨监控群里刷了一排红色,我看了一眼日志,大概就是这个味道:

HikariPool-1 - Connection is not available, request timed out after 30000ms.

第一反应很朴素:连接池太小了。

于是直接把 maximumPoolSize10 拉到 200,重启,发布,感觉稳了。结果第二天高峰期又炸了,不过这次不是应用炸,是数据库先顶不住了。

后来去看 MySQL:

SHOW GLOBAL STATUS LIKE 'Threads_connected';
SHOW VARIABLES LIKE 'max_connections';

连接数蹭蹭往上涨,其他服务也开始报同一个错。这时候我才意识到,微服务里的连接池真不是一个 application.yml 里的小数字,它最后会变成一个全局账本。

微服务里最坑的是乘法

如果是一个单体,连接池稍微设大点,最多就是本机线程池、本地 DB 压力变高,还好收拾。

微服务不一样,坑在于乘法。

大概公式长这样:

数据库总连接数 ≈ 服务实例数 × 每个实例的连接池最大连接数 × 实例里的数据源/连接池数量

听起来很抽象,实际很容易翻车。

举个例子,订单服务在 Kubernetes 里最小 2 个副本,高峰自动扩到 20 个副本,每个实例配置 maximumPoolSize=20,看起来不多。结果:

20 × 20 = 400

一个订单服务就能把 MySQL 默认常见的 151 打穿。

更离谱的是,微服务拆分以后,每个服务都觉得自己“就几个连接池而已”,结果 6 个服务一起扩,总连接数直接起飞。

所以连接池第一个设计点不是“这个服务要多少”,而是“这个服务最多能占多少,会不会挤死别人”。

下面这段是我后来实际用的配置,仅供参考,别直接抄到生产:

spring:
  datasource:
    hikari:
      pool-name: order-hikari
      maximum-pool-size: 16
      minimum-idle: 16
      connection-timeout: 3000
      max-lifetime: 900000
      keepalive-time: 300000
      leak-detection-threshold: 60000

这里每个数字都有代价。

maximum-pool-size 决定单个实例最多抢多少 DB 连接。
minimum-idle 太低的话,流量一来要先创建连接,延迟会变高。
connection-timeout 不能无限等,否则请求线程和连接一起卡死。
max-lifetime 要比 MySQL 的 wait_timeout 小一点,不然应用里拿着一个已经过期的连接去查,线上很容易出现那种“偶发但死活复现不了”的报错。

我最后不是拍脑袋,是按容量反推

之前加连接池基本靠感觉:不够了就加,加了就重启。

后来发现这玩意儿可以用 Little's Law 反推一下:

活跃连接数 ≈ QPS × 平均连接持有时间

注意是“平均连接持有时间”,不是单条 SQL 时间。

订单服务一个接口可能查一次库存、写一次订单、再更新一次状态,这些操作如果在一个事务里,连接会一直被占着。你以为 SQL 只有 8ms,实际事务里可能拿连接拿了 30ms

简单算一下:

峰值 QPS:240
平均持有时间:15ms = 0.015s
理论活跃连接数:240 × 0.015 = 3.6

看起来不大,但是线上不能按平均值活着。

我会再考虑几个东西:

所以我一般会留一个安全系数,常见取 1.5 ~ 2 倍。

接着按刚才例子算:

3.6 × 2 ≈ 7.2

如果这个服务最多 20 个 Pod,单实例连接池设 16 就明显危险了,因为总量可能接近:

20 × 16 = 320

这时候要么降单实例池大小,要么限制最大副本数,要么把数据库连接总量提上去。

微服务里连接池设计,很多时候不是应用团队自己拍,而是和 DBA、架构一起算出来的。

超时是逃生舱,不是装饰

连接池满的时候,请求会排队。

但排队本身不一定是坏事,可怕的是排得久、排得乱,最后把上游线程池也拖死。

我最开始也设过一种很离谱的配置:

connection-timeout: 30000

看着很“稳”,实际就是给雪崩留了 30 秒。

后来我改成:

connection-timeout: 3000

意思是:拿不到连接就快速失败,然后让上层接口返回降级结果、重试消息、或者直接报给调用方。

当然,这还不够,因为真正占着连接的可能不是“拿不到”,而是“拿到了但 SQL 慢”。

所以我会配套限制 SQL 和事务:

jpa:
  open-in-view: false
  properties:
    hibernate.query.timeout: 3000
    hibernate.jdbc.batch_size: 50
  transaction:
    default-timeout: 10

有些关键接口我会直接在代码里标:

@Transactional(timeout = 10)
public void createOrder(OrderCreateRequest request) {
    // ...
}

别小看这个 timeout。线上经常有一种情况:一个没加索引的 SQL 把连接池占满,其他正常请求进不去。你以为是连接池容量不够,其实是一个慢 SQL 在拖全班。

连接池不是限流器,也不是背锅侠。

如果你把连接池当限流器用,比如故意设 maximumPoolSize=1 来保护数据库,那大概率只是把等待从 DB 转移到了应用线程池,最后接口超时更难看。

没有监控,就别谈权衡

后面我学乖了,不再靠日志猜,而是先把 Prometheus 指标捞出来。

本地如果开了 Actuator,可以先看一眼:

curl -s localhost:8080/actuator/prometheus | grep hikaricp_connections

我主要盯几个:

其中我最看重的其实是 pendingtimeout

因为 active 高不一定是问题,只要没超时,业务可能还是好的。
pending 一高,说明已经有人在等连接了。
如果 pending 连续高几分钟,基本就是容量、慢 SQL、实例数三者有一个出问题了。

一个简单的报警规则可以长这样:

- alert: HikariConnectionsPendingHigh
  expr: hikaricp_connections_pending > 5
  for: 2m
  labels:
    severity: warning

- alert: HikariConnectionTimeout
  expr: rate(hikaricp_connections_timeout_total[1m]) > 1
  for: 2m
  labels:
    severity: critical

实际生产阈值得根据服务量级改,别直接抄。

还有个事,Prometheus 抓指标最好别只抓当前 Pod。微服务动态扩缩容的时候,旧 Pod 销毁、新 Pod 上来,你如果没有按 instance / pod 维度拆开看,很容易以为“指标怎么突然掉 0 了”,其实是 Pod 没了。

HTTP 连接池也是同一个故事

这篇文章标题虽然像数据库连接池,但微服务里 HTTP 下游连接池更阴间。

比如服务 A 调服务 B,用了 Feign、RestTemplate、WebClient 或者原生 HttpClient,连接池没设好,慢接口一来,A 自己的请求线程全卡住,然后 A 的上游也开始超时。

这个链路比数据库更隐蔽,因为你会在 A 的错误日志里看到一堆 timeout,但真正慢的是 B。

我会特别注意:

feign:
  client:
    config:
      default:
        connectTimeout: 1000
        readTimeout: 3000

ribbon:
  ConnectTimeout: 1000
  ReadTimeout: 3000

不过现在新项目里我会更建议显式配置底层 HTTP 客户端的连接池,而不是只靠 Feign 默认值。

一句话经验:数据库连接池打爆的是 DB,HTTP 连接池打爆的是线程。线程没了,服务就直接装死。

几个很容易忽略的小坑

1. minimumIdlemaximumPoolSize 差太远

有些配置会这样:

maximum-pool-size: 50
minimum-idle: 1

平时看起来省资源,流量一来就要不断创建连接,MySQL 认证、网络握手、初始化都会抖。

对于稳定运行的服务,我一般会让他们接近,比如:

maximum-pool-size: 16
minimum-idle: 12

或者干脆一样:

maximum-pool-size: 16
minimum-idle: 16

当然也要看连接数预算。

2. 连接泄漏比连接数不够更恶心

leak-detection-threshold 打开以后,如果代码里拿了 Connection 没关,会打日志。

别嫌日志烦,线上真遇到的时候能救命。

不过阈值别设太低,否则正常长事务也可能误报。我一般会设 60000ms,至少先能抓到明显没关连接的代码。

3. 多数据源一定分清池子

一个服务里可能同时有:

每个都要算。

最怕的是大家共用一个“我觉得够用”的默认值,最后谁都说不清谁在消耗连接。

给每个池子起名字很重要,比如 order-hikarireport-hikariredis-pool,监控和日志里一眼就能区分。

我现在的判断顺序

如果线上出现连接池不够,我现在基本不先加池子了,我会按这个顺序查:

第一看 pending 是不是真的持续高。

如果不高,可能只是个别超时,或者报警阈值太敏感。

第二看 active 是不是贴着 maximumPoolSize 跑。

如果是,再看数据库连接数、实例数、单实例池大小有没有失控。

第三看慢 SQL、慢下游、锁等待。

如果这些不处理,加大池子只是让更多人排队。

第四看是不是发布或者 HPA 扩容导致总连接数突增。

微服务里经常不是代码变了,而是 Pod 数量变了,数据库没变。

最后一点心得

连接池这东西,平时看起来就是一段配置,真正出事的时候全是权衡。

小了会拒绝服务,大了会打死下游;短了会误伤,长了会雪崩;只看单服务很合理,看全局可能已经爆炸。

我现在比较喜欢先把数字算清楚,再把池子配出来,最后一定挂监控。

没监控的连接池,基本等于赌博。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放