凌晨两点十七分,手机在床头柜上震个不停。订单服务开始大面积报错,日志里刷屏的是同一句话——“Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool.” 我爬起来连上跳板机,先看了一眼应用侧连接池监控,active 顶在 100 一动不动,pending 排了两百多;再看 SQL Server,用户会话数不到 60,CPU 才 18%,worker 数连一半都没用上。数据库明明很闲,应用却排着队拿不到连接。那一刻我就知道,这不是数据库扛不住,而是连接池和并发模型这两本账从来没对齐过。
这篇文章就围绕 SQL Server 的最大并发和数据库连接池展开。我会把“最大并发到底限制的是什么”“连接池内部怎么复用连接”“池大小到底该设多少”“出现连接池耗尽该怎么查”这几件事一次讲透,中间会给出可以直接抄的连接串参数、HikariCP 配置、诊断 SQL 和压测方法。不管你是刚接手 SQL Server 的后端新人,还是被连接数问题折腾过几轮的老手,希望看完能少走点我走过的弯路。文中涉及的参数默认值和计算公式,我都尽量标了来源和适用前提,具体环境还需要你按实测结果修正。
1. 先把概念掰开:最大并发和连接池不是一回事
1.1 “最大并发”在不同语境下指的东西完全不同
很多人在讨论“SQL Server 最大并发是多少”时,其实心里想的可能是三件毫不相干的事。第一件是用户连接上限,也就是同时能有多少个会话挂在这个实例上,SQL Server 的max user connections默认是 0,意思是自动,实际硬上限是 32767。第二件是工作线程上限,也就是同一时刻能有多少个 worker thread 在真正执行任务,这个值由max worker threads控制,默认跟 CPU 核数挂钩,8 核机器上是 576。第三件是业务语义上的并发,比如你的订单系统每秒能处理多少笔下单请求,这个数字其实是由锁、事务隔离级别、磁盘 IOPS 和业务逻辑长度共同决定的,跟前面两个数字化关系不大。
这三件事混在一起谈,就会出现开头那种尴尬场面:你盯着max user connections看,觉得 32767 大得没边,于是放心地把连接池往上调;调完发现数据库还是慢,于是又去加 CPU;加了 CPU 之后 worker 上去了,等待反而变成锁阻塞。真正的高手做容量规划时,是先把这三个数字分别量出来,再看哪一个先成为瓶颈,而不是一上来就找一个“最大并发数”往上套。
1.2 连接池真正解决的问题是“连接建立成本”
理解连接池要抓住一个关键词:复用。SQL Server 建立一条新连接的成本其实不低——TCP 三次握手、TDS 预登录握手、身份认证、在这个实例上注册会话上下文、分配内存结构,整套走完通常要几十毫秒,遇到域认证或者加密连接会更慢。如果每条 SQL 都新建连接再关掉,那你的 QPS 天花板就被建连速度锁死了。
连接池的做法是:第一次建连成功后不真正关闭,而是把它还回池里,下次请求直接从池里拿走,用完再还回去。这样一来,绝大多数请求走的都是“取—用—还”三步,省掉了建连开销。ADO.NET 的连接池默认就是开启的,你写using var conn = new SqlConnection(cs)然后conn.Open(),池已经在后面默默工作了,不需要你显式管理。
不过要注意,池是按连接字符串分组的。同一个进程里,只要连接串有一个字符不一样,ADO.NET 就会认为这是两个不同的目标,给你建两个独立的池。这个特性坑过太多人,后面第 6 章我会专门讲一个真实案例。
1.3 两者在什么地方会撞车
撞车点就在总量约束上。连接池是一个“放大器”:你有一个池,池里 40 条连接,服务部署 20 个实例,那么峰值时刻数据库上就会看到 800 个会话。这 800 个会话并不都会同时干活,但 SQL Server 需要为每一个都保留会话结构、登录上下文和一部分内存。
更麻烦的是 worker thread。会话是“登记在册”,worker 是“实际干活”。SQL Server 的线程模型允许少量线程支撑大量会话,因为大部分会话在大部分时间是等待状态。但如果你的业务里存在大量长事务、慢查询、并行查询,等待状态会迅速变成运行状态,worker 就会被占满,这时候新的请求就开始排THREADPOOL等待——这个等待类型一旦出现在统计表前列,基本就意味着线程层面已经饱和了。
所以一个成熟的容量模型应该写成:应用实例数 × 单实例池上限 × 峰值活跃比例 ≤ 合理 worker 占用。左边是你可控的,右边是你需要给数据库留的余量。很多人只调左不调右,或者反过来,结果就是两边各自都“没问题”,合起来出问题。
2. SQL Server 侧的并发天花板由谁决定
2.1 用户连接上限:形式上的天花板
查用户连接上限很简单,在 SSMS 或者 Azure Data Studio 里跑一句:
EXEC sp_configure 'show advanced options', 1; RECONFIGURE; EXEC sp_configure 'user connections';第二个结果集的config_value如果是 0,就是自动配置,实际能力是 32767。这个数字看起来大得离谱,但它是形式上的上限,不是能力上限。真正会先撞到的通常是内存和 worker,然后是许可和 CPU。
我见过有人为了“防止连接打满”,把user connections显式设成 200。这个做法要非常小心:一旦到 200,第 201 个连接申请会直接失败,应用侧看到的是登录失败的报错,而不是排队等待。这个策略适合给某些报表库做保护,但用在核心交易库上,等于把弹性完全掐死了。我的建议是保持自动,把限制做在应用侧的池上限上,因为池上限是可观测、可灰度、可回滚的,而实例级别的硬上限一旦生效就是全站生效。
另外顺手提一句,SQL Server 的许可和连接数没关系,它按核心或者按用户数(CAL)来算,所以“买更多连接数”这个说法在 SQL Server 上是没有意义的。
2.2 工作线程:真正的并发闸门
工作线程才是那个真正决定“同一时刻能干多少活”的数字。查询方式:
SELECT max_workers_count FROM sys.dm_os_sys_info; SELECT COUNT(*) AS current_workers FROM sys.dm_os_workers;max_workers_count的默认值在 64 位系统上有一套规则:4 核及以下是 512,之后每增加 4 个逻辑 CPU 增加 64,8 核是 576,16 核是 704,32 核是 960,64 核是 1152,超过 64 核就固定在 1152 附近不再大幅增长。这套规则背后的思路是:CPU 越多,单个查询被拆成并行的可能性越大,需要的线程自然更多,但增长是要收敛的,否则线程上下文切换的开销会吃掉并行带来的收益。
从 SQL Server 2016 开始,max worker threads支持设为 0 表示自动,2019 和 2022 默认就是自动,我建议保持自动,不要手动写死。原因很简单:手动值如果设小了,高峰期会莫名出现THREADPOOL等待;设大了,在内存紧张的时候每个线程栈都占内存,反而更容易触发内存压力。
实测中判断线程是否吃紧,可以看调度器上的可运行任务数:
SELECT scheduler_id, current_tasks_count, runnable_tasks_count, current_workers_count, active_workers_count FROM sys.dm_os_schedulers WHERE scheduler_id < 255;runnable_tasks_count长期大于 0,说明有任务在 CPU 队列上排队;如果所有调度器都排队,那是 CPU 真的不够。这个指标配合等待统计一起来看,判断会准得多。要特别注意的是,一条并行查询会占用多个 worker,如果MAXDOP设成 8,一条大查询理论上最多吃掉 8 个 worker,几十条并行查询同时跑,worker 消耗速度会远超你的直觉。
2.3 内存授予与并行度:两把双刃剑
SQL Server 在执行排序、哈希连接、哈希聚合这类操作之前,需要先拿到一块内存授予(memory grant)。授予的大小是根据统计信息和行数估算出来的,然后由一个叫RESOURCE_SEMAPHORE的信号量统一发放。如果估算偏大,一块内存被一条查询长期占着,其他查询就得在外面等,等待类型就是RESOURCE_SEMAPHORE。
这个等待非常隐蔽——从应用侧看,SQL 就是慢,慢得没有道理,CPU 不高、IO 不高、锁也没有;从数据库侧看,就是内存授予排队。解决办法通常有三条:一是把统计信息更新到位,让估算准一点;二是给实例设置合理的max server memory,别让操作系统和 SQL Server 抢内存;三是调min memory per query,不过这个参数动了会影响全局,我一般先不碰它。
并行度这边,cost threshold for parallelism默认值是 5,这个值是 1990 年代硬件条件下定的,放到今天明显偏低,导致很多本来该串行执行的小查询被强行并行化,产生大量CXPACKET等待。OLTP 库上我通常建议把它调到 40 到 50,同时把MAXDOP限制在 4 到 8 之间。这不是“并行不好”,而是小查询并行的拆分开销大于收益,CXPACKET本身是并行正常工作的标志,只有在它伴随高wait_time_ms同时 CPU 打满时,才说明并行粒度不合适。
2.4 阻塞才是最会伪装成“并发不足”的问题
以我的经验,十个声称“数据库并发不够”的案例里,至少有六个真正的病根是阻塞。典型特征是:连接池全部停在 active,CPU 不高,会话数不多,但每条 SQL 的total_elapsed_time都在几秒以上,blocking_session_id一查就指向同一个会话。
诊断的第一步是找出那个“源头会话”:
SELECT r.session_id, r.blocking_session_id, r.wait_type, r.wait_time, r.command, r.total_elapsed_time, t.text AS sql_text FROM sys.dm_exec_requests AS r CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS t WHERE r.blocking_session_id <> 0 ORDER BY r.wait_time DESC;如果阻塞者是BEGIN TRAN开了以后一直没提交的业务代码,那就不是加连接池能解决的问题。这时候你去加大池,只会让更多连接挤在同一个锁上,把锁等待队列拉得更长。正确顺序永远是:先解决阻塞和慢查询,再谈池大小。这个顺序搞反了,调优就变成了朝错误方向加速。
3. 连接池内部机制拆解
3.1 ADO.NET 连接池到底是怎么运作的
ADO.NET 的连接池结构不复杂,你可以理解成几个队列:一个空闲连接队列、一个已占用集合,加上一组计数器。当你调用Open()时,池先检查当前总连接数有没有超过Max Pool Size,没超过就从空闲队列取一条(取的时候会做一次轻量校验,比如确认连接没被服务端主动断掉),取到就交给调用方;如果空闲队列是空的但总数还没超上限,就新建一条;如果总数已达上限,请求就进入等待,等别人归还或者等Connection Timeout到期后抛异常。
关键点在于:Connection Timeout计的是从池中获取连接的总等待时间,不只是网络握手的时间。很多人误以为它只是建连超时,于是把它设成 30 秒,结果高峰期应用线程被大量挂在这个等待上,线程池也被拖垮了。正确的做法是把它设得短一点,比如 3 到 5 秒,让失败快速暴露出来,而不是把问题埋在一个漫长的等待里。
Close()或者Dispose()的时候,连接不是真的断掉,而是被Connection Reset机制清一下会话状态(回滚未提交事务、重置 SET 选项等),然后放回空闲队列。这也是为什么忘记Dispose的代码会泄漏连接——连接没还回去,池里的可用数就少一条,泄漏够多,池就彻底卡死。
3.2 连接串参数逐个说清
.NET 侧 SQL Server 的连接串参数不多,但每一个都值得细究。下面这张表是我自己整理的常用项,取值建议基于 OLTP 场景:
| 参数 | 默认值 | 建议值 | 说明 |
|---|---|---|---|
| Pooling | true | true | 关了等于每次新建连接,基本不要关 |
| Min Pool Size | 0 | 5 到 10 | 预热连接,避免冷启动尖刺 |
| Max Pool Size | 100 | 按实测定 | 单实例上限,见 3.4 的计算 |
| Connection Timeout | 15 | 3 到 5 | 从池取连接的总等待上限 |
| Connection Lifetime | 0 | 0 或 300 到 600 | 超过存活期的连接在归还时被销毁,用于负载均衡场景 |
| Load Balance Timeout | 0 | 同 Connection Lifetime | 上面那个参数的别名 |
| Connection Reset | true | true | 归还时重置会话状态,强烈建议保持 |
| MultipleActiveResultSets | false | 谨慎开 | 开启后可在一个连接上并发读多个结果集,但有额外开销 |
| Application Name | 空 | 必填 | 会出现在sys.dm_exec_sessions.program_name里,排查全靠它 |
Application Name这一项被严重低估。生产环境的会话表里如果只能看到.Net SqlClient Data Provider,你根本分不清哪条连接来自订单服务、哪条来自报表任务。把它填上,排查时会省下大量时间。同理,Java 侧要填applicationName。
还有一个容易踩的点:连接串里的Encrypt。新版本驱动默认会开启加密并校验证书,如果服务器用的是自签证书,你会看到证书链错误。开发环境可以加TrustServerCertificate=True临时绕过,生产环境建议把证书链配好,别长期用这个开关兜着。
3.3 Java 侧 HikariCP 与 Druid 的适配要点
Java 生态里连 SQL Server 主流是 HikariCP,Spring Boot 2.x 之后也是默认。HikariCP 的参数设计和 ADO.NET 不太一样,最需要分清的是这几个:
maximumPoolSize默认 10,这是池的上限,包含空闲和活跃连接。minimumIdle默认等于maximumPoolSize,也就是说默认行为是“一直保持满池”。想省资源可以调小,但要注意流量突增时的补连接开销。connectionTimeout默认 30000 毫秒,最小值 250。这是从池里拿连接的等待上限,生产上建议 3000 到 5000。idleTimeout默认 600000 毫秒,最小值 10000,只对超出minimumIdle的连接生效。maxLifetime默认 1800000 毫秒,最小值 30000。这个值必须比数据库或网络层的空闲超时短一截。keepaliveTime默认 0(禁用),最小值 30000,用于给空闲连接发心跳,防止被网络设备回收。leakDetectionThreshold默认 0(禁用),建议在预发环境设成 10000 到 20000,超过这个时间没归还就打印堆栈。validationTimeout默认 5000 毫秒,最小值 250。
这几个参数里,maxLifetime和keepaliveTime是我最常调的两个。原因在第 6 章会展开:长连接被中间网络设备悄悄回收,是那种“平时都没事,一到夜里就报错”的经典故障。
3.4 池大小到底该设多少
这个问题没有一个万能数字,但有一个可以当起点的经验公式,来自 HikariCP 的官方说明:connections = ((core_count * 2) + effective_spindle_count)。比如 4 核机器配 SSD,算出来大概是 9。这个公式原本是给 PostgreSQL 场景写的,SQL Server 上可以当作起点,但必须结合三件事修正。
第一是总连接预算。假设你在 Kubernetes 上跑 20 个 Pod,每个 Pod 池上限 20,那数据库上最坏情况就是 400 个会话。这时候去看实例的max_workers_count,如果是 8 核的 576,表面上够用,但要扣掉:并行查询额外占用的 worker、备份和索引维护的 worker、系统任务预留。我一般的经验是把总连接数控制在 worker 数的 50% 到 70% 之间,也就是 8 核机器上总连接别超过 300 到 400 这个量级。
第二是单请求持有连接的时长。如果你的接口平均 20 毫秒,那 10 条连接理论上能撑住 500 QPS;如果某个接口要跑 2 秒,那它一条连接就吃掉 100 倍的资源。所以池大小必须结合 P99 响应时间来算,而不是拍脑袋。
第三是数据库侧的实际承载。池调大之后,压测时盯着THREADPOOL、RESOURCE_SEMAPHORE、LCK_*这几类等待,只要它们开始明显增长,就说明池已经过大了,光靠“池里有空闲连接”判断是不够的。
4. 一次完整的调优实操
4.1 现状摸底:先把当前连接和等待抓出来
动手改配置之前,先把现场拍下来。我习惯按顺序跑这几组语句。第一组看连接分布:
SELECT DB_NAME(dbid) AS db_name, COUNT(*) AS session_count FROM sys.sysprocesses WHERE dbid > 0 GROUP BY DB_NAME(dbid) ORDER BY session_count DESC;第二组看谁在连、在等什么,这一组信息量最大:
SELECT s.session_id, s.login_name, s.host_name, s.program_name, s.status, r.command, r.wait_type, r.wait_time, r.blocking_session_id, r.cpu_time, r.total_elapsed_time FROM sys.dm_exec_sessions AS s LEFT JOIN sys.dm_exec_requests AS r ON s.session_id = r.session_id WHERE s.is_user_process = 1 ORDER BY r.total_elapsed_time DESC;第三组看累计等待,判断瓶颈类型。清空等待统计需要权限,如果没权限就直接看累计值,重点看排在前面的是哪几类:
SELECT TOP 20 wait_type, waiting_tasks_count, wait_time_ms, wait_time_ms - signal_wait_time_ms AS resource_wait_ms FROM sys.dm_os_wait_stats WHERE wait_type NOT LIKE '%SLEEP%' AND wait_type NOT LIKE 'BROKER%' ORDER BY wait_time_ms DESC;看结果的思路是:LCK_M_*排在前面说明阻塞严重;PAGEIOLATCH_*说明磁盘读慢;RESOURCE_SEMAPHORE说明内存授予排队;THREADPOOL说明 worker 紧张;CXPACKET单独出现不用慌,配合高 CPU 才说明并行策略需要调整。顺带说一句,这些查询用 SSMS、Azure Data Studio、DBeaver 或者 Navicat 连上去跑都一样,工具不影响结论,SQL Server 的免费版本 SQL Server Express 也有sys.dm_exec_*系列视图,做初步排查足够用。
4.2 连接串与数据源配置落地
摸清现状后开始改配置。SQL Server JDBC 连接串有两套写法,老式的jdbc:sqlserver://host:1433;databaseName=xxx和新式的 URL 属性形式,两者混用容易出错,我建议统一用分号形式。下面是我在 Spring Boot 项目里常用的配置:
spring: datasource: url: jdbc:sqlserver://10.0.0.12:1433;databaseName=OrderDb;encrypt=true;trustServerCertificate=true;loginTimeout=10;socketTimeout=60000;sendStringParametersAsUnicode=false;applicationName=order-api username: app_user password: ${DB_PASSWORD} hikari: pool-name: order-hikari maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1740000 keepalive-time: 300000 validation-timeout: 3000 leak-detection-threshold: 0几个参数要解释一下。max-lifetime设成 1740000 毫秒是 29 分钟,比常见的 30 分钟空闲回收阈值短一点,这是刻意留的安全边际。keepalive-time给 5 分钟,让空闲连接定期发个心跳。connection-timeout只给 3 秒,是为了让池耗尽的问题快速暴露,而不是把应用线程挂住。
.NET 侧对应的写法:
var cs = new SqlConnectionStringBuilder { DataSource = "10.0.0.12,1433", InitialCatalog = "OrderDb", UserID = "app_user", Password = Environment.GetEnvironmentVariable("DB_PASSWORD"), MinPoolSize = 5, MaxPoolSize = 20, ConnectTimeout = 3, ApplicationName = "order-api", MultipleActiveResultSets = false, TrustServerCertificate = true }.ConnectionString;注意这里ConnectTimeout给的是 3 秒,而不是默认的 15 秒。这个改动看起来激进,但配合健康检查和快速失败策略效果很好——池拿不到连接时立刻报错,让上层决定是重试还是降级,比默默等 15 秒好得多。
4.3 压测与阶梯式调整
配置改完不能直接上生产,需要压测验证。我用 k6 或 JMeter 做阶梯加压,从 50 并发开始,每 2 分钟加 50,一直加到报错率超过 1% 或者 P99 超过阈值。压测期间同时采集三组指标:
应用侧看hikaricp_connections_active、hikaricp_connections_idle、hikaricp_connections_pending,其中 pending 是最重要的一个,它代表有多少请求在排队等连接。pending 长期大于 0,说明池小了,或者连接被慢查询占住了。
数据库侧每 30 秒跑一次 4.1 里的第二组查询,把结果存下来,重点是wait_type的分布变化和blocking_session_id的出现频率。我一般会把这两个指标画在同一条时间轴上,一眼就能看出是池先不够用还是数据库先顶不住。
压测的目标不是找一个“最大并发数”,而是找出池大小、响应时间、错误率三者的拐点。通常你会看到这样一条曲线:池从 10 加到 30 的过程中 P99 明显下降,加到 40 之后基本不动,加到 80 反而开始上升。那个开始上升的点往回退两步,就是比较稳的取值。我这次的项目最后定在 20,配合 20 个 Pod,总连接预算 400,8 核实例的 worker 576,留了足够余量。
4.4 上线后的监控与回滚预案
配置上线必须带监控和回滚方案。监控至少要覆盖四个指标:活跃连接数、等待连接数、连接获取耗时 P99、数据库侧会话总数。这四个指标任何两个同时异常,就值得报警。
回滚方案要做成配置化的,别写成代码。用配置中心或者环境变量控制池大小,出问题不用重新发版,改个值 refresh 一下就行。我那次调优上线后第二天早上又出现过一次短暂的 pending 抖动,监控立刻抓到了,5 分钟内把池上限从 20 临时提到 30 顶住早高峰,晚上流量下去之后再改回来分析根因,整个过程没有用户感知。
还有一点经验:把连接池的关键指标接入到你现有的监控体系里,而不是新建一套。这样告警规则、值班流程都不用重做,接入成本低,团队也更容易接受。
5. 常见故障速查与排查套路
5.1 连接池等待超时
应用侧抛Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool或者 HikariCP 抛Connection is not available, request timed out after 3000ms,这是最常见的症状。排查顺序不要乱:
第一步,看数据库侧当前用户会话总数。如果远小于“实例数 × 池上限”,说明池里大量连接被业务长时间占着没归还,去查慢查询和长事务。第二步,看应用侧活跃连接数和等待数的时间曲线,如果活跃数长期贴着上限,就是池小或者有泄漏。第三步,开启leakDetectionThreshold(预发环境),跑到 10 到 20 秒还没归还的连接会被打印出创建堆栈,泄漏点一目了然。
要特别提醒的是,池耗尽时不要第一反应就是加大池上限。如果根因是某条 SQL 突然变慢,加大池只是让更多连接卡在同一个等待上,反而把故障面扩大。
5.2 登录阶段超时与网络层问题
另一类报错长这样:“在与 SQL Server 建立连接时出现与网络相关的或特定于实例的错误”,或者“登录超时已过期”。这类问题出在建连阶段,而不是池等待阶段,排查方向完全不同。
先确认端口和协议:SQL Server 配置管理器里 TCP/IP 协议是否启用,是否在监听 1433 或者自定义端口,防火墙规则是否放通。如果是命名实例,还要确认 SQL Server Browser 服务是否在跑,客户端能不能解析到实例名。再确认网络路径:从应用容器里telnet一下数据库端口,能通说明 TCP 层没问题,问题在认证或者 TDS 握手。最后看驱动版本,不同版本 JDBC 驱动对加密和证书的默认行为不一致,升级驱动后突然连不上,先怀疑这里。
5.3 线程耗尽与 THREADPOOL 等待
THREADPOOL等待出现,说明数据库已经开始给不了线程了。这时候要区分两种成因。一种是连接数确实过多,会话虽然大多在等待,但需要 worker 的时刻意外集中,比如定时任务同时启动。另一种是并行查询占用的 worker 过多,MAXDOP设得太大,一条大查询吃掉十几个 worker。
判断方法是同时看 worker 数和runnable_tasks_count。worker 数接近上限、runnable 普遍大于 0,就属于前者;worker 数没到上限但CXPACKET很高,属于后者。前者要压总连接数和排查连接泄漏,后者要调MAXDOP和cost threshold for parallelism。两种情况的处理手段完全不同,别混着用。
5.4 问题速查表
把上面的排查路径整理成一张表,出问题时按现象对号入座:
| 现象 | 最可能的原因 | 优先动作 |
|---|---|---|
| 池等待超时,数据库会话数正常 | 慢查询或长事务占住连接 | 查sys.dm_exec_requests按耗时排序 |
| 池等待超时,数据库会话数接近池上限总和 | 池上限过大或连接泄漏 | 开leakDetectionThreshold,收缩池上限 |
| 建连阶段超时 | 端口、防火墙、实例名解析或证书问题 | 从容器内测端口连通性,核对驱动版本 |
| 报错伴随会话数远超市面预期 | 连接串不一致导致池分裂 | 统一连接串,用program_name核对 |
THREADPOOL等待排前列 | 连接总量过大或并行度过高 | 压总连接预算,调MAXDOP到 4 到 8 |
RESOURCE_SEMAPHORE等待增长 | 内存授予排队,统计信息不准 | 更新统计信息,设置max server memory |
| 夜间固定时间出现连接错误 | 长连接被网络设备空闲回收 | 调短maxLifetime,开启keepaliveTime |
6. 我踩过的坑和一些不太写在文档里的经验
6.1 连接串不一致会让连接池悄悄分裂
这是我踩过最隐蔽的一个坑。有一次业务方反馈连接数监控显示数据库上有 800 多个会话,但按我们的部署模型算,最多也就 400。查了三天,最后发现是两段代码里的连接串不一样:一段用了Application Name=OrderApi,另一段是Application Name=orderapi,大小写不同。ADO.NET 按连接串精确匹配分组,这就变成了两个池,每个池各自维护 100 条上限,总数直接翻倍。
教训是:连接串一定要统一从配置中心下发,禁止在代码里硬编码,并且在启动日志里把连接串的“脱敏摘要”打出来,方便核对。更稳妥的做法是在数据库侧按program_name做聚合统计,如果发现同一个服务出现多个 program_name,基本就是池分裂了。
6.2 JDBC 驱动的 sendStringParametersAsUnicode 默认值是个陷阱
这个参数前面配置里我设成了false,很多人看到会问为什么。微软的 SQL Server JDBC 驱动里,sendStringParametersAsUnicode默认是true,意思是所有字符串参数都按 Unicode 发送。如果你的表里是varchar列,一个nvarchar参数去比较,就会触发隐式转换——SQL Server 会把列的字符集提升到 nvarchar,索引直接失效,扫描全表。
这个话题跟“SQL Server 字符串转数字”是同一类问题,本质都是数据类型不匹配引发的隐式转换。除了字符串类型,参数类型和列类型不一致(比如传了字符串去比较数字列、传了日期字符串去比较 datetime 列)也会触发同样的问题,而且这类转换会让执行计划变得非常不稳定。检查方法是在实际执行计划里找CONVERT_IMPLICIT关键字,出现了就要警惕。
需要说明的是,改成false的前提是你的列确实都是varchar并且业务不需要存非 ASCII 字符。如果列是nvarchar,那就保持默认值,改了反而会引乱码。这个参数没有绝对正确的取值,只有跟你的表结构匹配的取值。
6.3 长连接被中间设备悄悄回收
有一段时间,每天凌晨会集中出现一批“连接被强制关闭”的报错。应用侧日志显示连接已经打开,写数据时报连接断开。查下来是链路中间的负载均衡器对空闲连接有回收策略,超过一段时间没有流量就静默断掉,而两端都不知道。
解决办法就是让池自己管住连接的寿命:maxLifetime设得比链路上最短的空闲回收时间还短,keepaliveTime定期发心跳。具体值要问运维拿,没有统一答案。我这次拿到的是 30 分钟,所以maxLifetime设 29 分钟,keepaliveTime设 5 分钟。改完之后夜间的报错彻底消失了。这个经验是:任何跨网络的长连接,都要假设中间设备会把它掐掉,主动管理寿命比被动重连靠谱。
6.4 池大小不是性能旋钮,它只是资源配置
最后说一个观念上的坑。我见过不少团队把连接池大小当成性能调优的第一手段,出问题就往上加,从 20 加到 50 加到 100,加到数据库开始抖动,再加机器。这个过程里真正的瓶颈一次都没被定位过。
连接池是个资源配置,不是性能旋钮。它的作用是把有限的数据库连接资源合理地分配给应用请求,保证在压力下有序排队而不是雪崩。真正的性能来自:索引对不对、SQL 写得够不够窄、事务够不够短、锁粒度合不合理、热点数据有没有缓存。这些做完之后,池大小往往只需要在一个不大的区间里微调。
我现在做容量规划的习惯顺序是:先看慢查询,再看锁等待,再看内存授予,最后才看连接池。这个顺序反过来,就是给自己挖坑。
补充一个实用小技巧:把sys.dm_exec_sessions里的program_name、host_name、login_name三个字段定时采样落库,保留最近 7 天。出故障时不用现场抓,翻历史数据就能看出连接数是从什么时候开始涨的、是哪个服务涨的、涨的时候在跑什么。这个轻量采集的成本很低,但在排障时的价值极高,我现在的每个 SQL Server 实例上都跑着这么一个采样任务。