
凌晨两点十七分手机在床头柜上震个不停。订单服务开始大面积报错日志里刷屏的是同一句话——“Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool.” 我爬起来连上跳板机先看了一眼应用侧连接池监控active 顶在 100 一动不动pending 排了两百多再看 SQL Server用户会话数不到 60CPU 才 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 增加 648 核是 57616 核是 70432 核是 96064 核是 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 阻塞才是最会伪装成“并发不足”的问题以我的经验十个声称“数据库并发不够”的案例里至少有六个真正的病根是阻塞。典型特征是连接池全部停在 activeCPU 不高会话数不多但每条 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 场景参数默认值建议值说明Poolingtruetrue关了等于每次新建连接基本不要关Min Pool Size05 到 10预热连接避免冷启动尖刺Max Pool Size100按实测定单实例上限见 3.4 的计算Connection Timeout153 到 5从池取连接的总等待上限Connection Lifetime00 或 300 到 600超过存活期的连接在归还时被销毁用于负载均衡场景Load Balance Timeout0同 Connection Lifetime上面那个参数的别名Connection Resettruetrue归还时重置会话状态强烈建议保持MultipleActiveResultSetsfalse谨慎开开启后可在一个连接上并发读多个结果集但有额外开销Application Name空必填会出现在sys.dm_exec_sessions.program_name里排查全靠它Application Name这一项被严重低估。生产环境的会话表里如果只能看到.Net SqlClient Data Provider你根本分不清哪条连接来自订单服务、哪条来自报表任务。把它填上排查时会省下大量时间。同理Java 侧要填applicationName。还有一个容易踩的点连接串里的Encrypt。新版本驱动默认会开启加密并校验证书如果服务器用的是自签证书你会看到证书链错误。开发环境可以加TrustServerCertificateTrue临时绕过生产环境建议把证书链配好别长期用这个开关兜着。3.3 Java 侧 HikariCP 与 Druid 的适配要点Java 生态里连 SQL Server 主流是 HikariCPSpring 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;databaseNamexxx和新式的 URL 属性形式两者混用容易出错我建议统一用分号形式。下面是我在 Spring Boot 项目里常用的配置spring: datasource: url: jdbc:sqlserver://10.0.0.12:1433;databaseNameOrderDb;encrypttrue;trustServerCertificatetrue;loginTimeout10;socketTimeout60000;sendStringParametersAsUnicodefalse;applicationNameorder-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总连接预算 4008 核实例的 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 到 8RESOURCE_SEMAPHORE等待增长内存授予排队统计信息不准更新统计信息设置max server memory夜间固定时间出现连接错误长连接被网络设备空闲回收调短maxLifetime开启keepaliveTime6. 我踩过的坑和一些不太写在文档里的经验6.1 连接串不一致会让连接池悄悄分裂这是我踩过最隐蔽的一个坑。有一次业务方反馈连接数监控显示数据库上有 800 多个会话但按我们的部署模型算最多也就 400。查了三天最后发现是两段代码里的连接串不一样一段用了Application NameOrderApi另一段是Application Nameorderapi大小写不同。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 实例上都跑着这么一个采样任务。