微服务架构下 连接池 的设计与权衡
一开始我以为只是把 10 改成 200
上周订单服务突然开始报错,凌晨监控群里刷了一排红色,我看了一眼日志,大概就是这个味道:
HikariPool-1 - Connection is not available, request timed out after 30000ms.
第一反应很朴素:连接池太小了。
于是直接把 maximumPoolSize 从 10 拉到 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
看起来不大,但是线上不能按平均值活着。
我会再考虑几个东西:
- 毛刺请求,比如某个慢查询突然把事务拖到
200ms - 同一服务多 Pod 同时启动,连接创建会抖一下
- 下游 RPC / Redis / MQ 慢,导致应用线程被占住
- 发布期间流量会集中到剩余 Pod 上
所以我一般会留一个安全系数,常见取 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
我主要盯几个:
hikaricp_connections_active:当前活跃连接数hikaricp_connections_idle:空闲连接数hikaricp_connections_pending:等待连接的线程数hikaricp_connections_timeout_total:获取连接超时次数hikaricp_connections_usage_seconds:连接使用时长
其中我最看重的其实是 pending 和 timeout。
因为 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. minimumIdle 和 maximumPoolSize 差太远
有些配置会这样:
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. 多数据源一定分清池子
一个服务里可能同时有:
- 主库数据源
- 只读库数据源
- 报表库数据源
- Redis 连接池
- MQ 连接池
- 下游 HTTP 连接池
每个都要算。
最怕的是大家共用一个“我觉得够用”的默认值,最后谁都说不清谁在消耗连接。
给每个池子起名字很重要,比如 order-hikari、report-hikari、redis-pool,监控和日志里一眼就能区分。
我现在的判断顺序
如果线上出现连接池不够,我现在基本不先加池子了,我会按这个顺序查:
第一看 pending 是不是真的持续高。
如果不高,可能只是个别超时,或者报警阈值太敏感。
第二看 active 是不是贴着 maximumPoolSize 跑。
如果是,再看数据库连接数、实例数、单实例池大小有没有失控。
第三看慢 SQL、慢下游、锁等待。
如果这些不处理,加大池子只是让更多人排队。
第四看是不是发布或者 HPA 扩容导致总连接数突增。
微服务里经常不是代码变了,而是 Pod 数量变了,数据库没变。
最后一点心得
连接池这东西,平时看起来就是一段配置,真正出事的时候全是权衡。
小了会拒绝服务,大了会打死下游;短了会误伤,长了会雪崩;只看单服务很合理,看全局可能已经爆炸。
我现在比较喜欢先把数字算清楚,再把池子配出来,最后一定挂监控。
没监控的连接池,基本等于赌博。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。