JDBC
JDBC 与数据库连接体系
从 JDBC 基础到分布式数据库连接治理的一份完整笔记。
目录
- 一、JDBC 基础
- 二、Java 建立 MySQL 连接的本质
- 三、长连接 vs 短连接
- 四、DriverManager vs DataSource
- 五、为什么还要 Hikari/Druid
- 六、Connection / Statement / ResultSet 的资源占用
- 七、GC 与资源释放
- 八、分布式场景下的连接数问题
- 九、max_connections 与保护上限
- 十、MySQL 水平扩容
- 十一、应用规模与容量估算
一、JDBC 基础
1.1 JDBC 是什么
JDBC 本身只是 JDK 定义的一组 interface(在 java.sql 和 javax.sql 包里),不连接任何数据库。真正干活的是各家数据库厂商实现的驱动 jar。
你的业务代码
↓ 调用
java.sql.Connection / Statement / ResultSet (interface,JDK 提供)
↓ 运行时绑定
com.mysql.cj.jdbc.ConnectionImpl / ... (mysql-connector-java 实现)
↓
TCP Socket → MySQL Wire Protocol → 3306 端口
1.2 三层架构
应用层: DAO / MyBatis / Hibernate
JDBC API 层 (java.sql / javax.sql): DriverManager, Connection, Statement,
PreparedStatement, ResultSet, DataSource
JDBC Driver 层 (厂商实现): MySQL Connector/J, PostgreSQL JDBC, Oracle OJDBC
网络层: TCP + 各家私有 Wire Protocol
数据库服务器
1.3 驱动类型(现代唯一选择:Type 4)
| 类型 | 名称 | 现状 |
|---|---|---|
| Type 1 | JDBC-ODBC 桥 | 淘汰,JDK 8 已移除 |
| Type 2 | Native-API 部分 Java | 淘汰 |
| Type 3 | 网络协议纯 Java | 极少见 |
| Type 4 | 纯 Java 网络协议 | 现代唯一选择 |
1.4 核心 API
DriverManager → 老式驱动加载器 + 建连工厂
DataSource → 新式建连工厂(推荐,支持连接池)
Connection → 一条数据库会话
Statement → SQL 执行器
↓ 继承
PreparedStatement // 预编译 + 参数占位符,首选
↓ 继承
CallableStatement // 调用存储过程
ResultSet → 查询结果游标
1.5 完整最小样例
String url = "jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=UTC";
try (Connection conn = DriverManager.getConnection(url, "user", "pwd");
PreparedStatement ps = conn.prepareStatement(
"SELECT id, name, age FROM users WHERE age > ? AND city = ?")) {
ps.setInt(1, 18);
ps.setString(2, "上海");
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
long id = rs.getLong("id");
String name = rs.getString("name");
}
}
}
1.6 三种 SQL 执行方法
| 方法 | 用途 | 返回 |
|---|---|---|
| executeQuery() | 只跑 SELECT | ResultSet |
| executeUpdate() | INSERT/UPDATE/DELETE/DDL | int(受影响行数) |
| execute() | 存储过程等多结果集 | boolean |
批量执行:
ps.setInt(1, 1); ps.addBatch();
ps.setInt(1, 2); ps.addBatch();
int[] counts = ps.executeBatch();
配合 MySQL 参数 rewriteBatchedStatements=true 才真正合并为一条 SQL。
二、Java 建立 MySQL 连接的本质
2.1 一句话概括
一个 JDBC Connection = 一条长期存活的 TCP 连接 + MySQL Wire Protocol 会话状态 + Java 对象封装。
┌─────────────────────────────────────────────────┐
│ Java 层: Connection conn (JDBC 对象) │
│ ↓ 内部持有 │
│ MySQL 协议层: 会话状态 (登录身份/当前 schema/ │
│ 事务/锁/预处理语句/session vars) │
│ ↓ 通过 │
│ Socket 层: java.net.Socket 对象 │
│ ↓ 封装 │
│ 内核 TCP: <本机IP:随机端口, MySQL:3306> │
│ 状态 = ESTABLISHED, 长期不关 │
└─────────────────────────────────────────────────┘
2.2 建连时发生什么
DriverManager.getConnection(url, user, pwd) 一行,背后 6 个步骤:
| 步骤 | 干了什么 | 耗时量级 |
|---|---|---|
| 1. DNS 解析 | host → IP | 0~几十 ms |
| 2. TCP 三次握手 | SYN / SYN-ACK / ACK | 1 RTT (~1-10ms 内网) |
| 3. (可选) TLS 握手 | 加密协商 | 1-2 RTT |
| 4. MySQL 握手包 | Server Greeting | 1 RTT |
| 5. 认证 | HandshakeResponse | 1 RTT |
| 6. 会话初始化 | USE db / 字符集 / autocommit | 若干 round-trip |
合计:内网 ~10ms,跨机房 ~50-200ms。这就是"每次 SQL 都新建连接会被打死"的根本原因 —— SQL 本身 1ms,建连比查询本身还贵 10-100 倍。
2.3 建完之后是长连接
- 内核 TCP 进入
ESTABLISHED状态,只要没主动关就一直活 - 上面反复跑
COM_QUERY/COM_STMT_EXECUTE,一条连接跑无数条 SQL - MySQL 端在
THD里保存会话状态 - 客户端和服务端都不主动关,直到
wait_timeout(默认 8h)或网络中断
2.4 必须搭配连接池
单条长连接同一时刻只能跑一条 SQL(MySQL 协议是半双工的)。HikariCP / Druid / tomcat-jdbc 就是干这个的:
- 池启动时预建 N 条连接
getConnection()→ 借,close()→ 归还不真关maxLifetime(HikariCP 默认 30 分钟)强制轮换keepaliveTime心跳保活,防 NAT 或防火墙静默丢弃
三、长连接 vs 短连接
3.1 现代主流:长连接
| 场景 | 组件 | 类型 |
|---|---|---|
| MySQL / Postgres | JDBC + 池 | 长连接 |
| Redis | Lettuce/Jedis | 长连接 |
| Kafka / RocketMQ | Client | 长连接 |
| Dubbo / gRPC | Netty | 长连接 |
| HTTP/1.1 | 默认 Keep-Alive | 长连接 |
| HTTP/2, HTTP/3 | 协议层强制 | 长连接 |
3.2 仍存在的短连接场景
- DNS 查询(UDP,天然无连接)
- Serverless / Lambda 冷启动
- Webhook / 回调通知
- K8s 健康检查探针
- 一次性脚本 / CLI 工具
- 传统 CGI 模式
3.3 长连接的代价
- 需要连接池管理
- 需要保活机制(TCP Keepalive / 应用心跳 / maxLifetime)
- 需要处理僵尸连接(NAT 超时、防火墙静默丢弃)
- 服务端资源占用(每连接一个 THD)
- 优雅关闭复杂(drain 现有连接)
四、DriverManager vs DataSource
4.1 DriverManager 的 5 个硬伤
1. 静态方法,不面向对象 — 不能注入、不能 mock、不能装饰
2. 连接信息硬编码 — 环境切换要改代码,密码进 Git 历史
3. 没法用 JNDI — 企业级容器无法统一管理数据源
4. 没有扩展点 — 无法插入连接池、监控、路由、XA
5. 并发瓶颈 — 内部 synchronized
4.2 DataSource 的优势
javax.sql.DataSource // 基础接口 —— 应用直接用
├─ javax.sql.ConnectionPoolDataSource // 物理连接工厂
└─ javax.sql.XADataSource // XA 分布式事务
- 接口化,可实现、可包装、可代理
- 可注入,Spring / 单元测试友好
- JNDI 友好,应用服务器统一管理
- 配置外置,不改代码切换环境
- 装饰器模式的钩子 —— 连接池、监控、路由都基于这个
4.3 时间线
1997 JDBC 1.0 只有 DriverManager
1999 JDBC 2.0 引入 DataSource + ConnectionPoolDataSource + XADataSource
2000s Apache DBCP、C3P0 出现
2012 HikariCP 发布
2013 Spring Boot 默认集成 HikariCP,事实标准
五、为什么还要 Hikari/Druid
5.1 厂商 DataSource 是什么
com.mysql.cj.jdbc.MysqlDataSource 只是把 DriverManager.getConnection() 换了个 OO 写法:
public Connection getConnection() throws SQLException {
// 每次都打开新 TCP + MySQL 握手 → 返回新连接
return new ConnectionImpl(...);
}
没有池,没有复用,没有监控。
5.2 Hikari/Druid 提供了什么
| 能力 | 厂商 DS | Hikari/Druid |
|---|:---:|:---:|
| 连接复用 | ❌ | ✅ |
| 并发控制 (maxPoolSize) | ❌ | ✅ |
| 健康检查 / 保活 | ❌ | ✅ |
| 超时治理 | ❌ | ✅ |
| 监控埋点 | ❌ | ✅ |
| SQL 拦截 / 防注入 | ❌ | ✅ (Druid) |
| 连接泄漏检测 | ❌ | ✅ (Hikari leakDetectionThreshold) |
| 支持 Spring 事务 | 事实上不能 | ✅ |
5.3 两种取物理连接的方式
Hikari 内部拿物理连接有两条路径:
路径 A (jdbcUrl):走 DriverManager → 找到 Driver → driver.connect(url, props)
路径 B (dataSourceClassName,官方推荐):反射 new MysqlDataSource() → setter → getConnection()
两条路最终都到 com.mysql.cj.jdbc.ConnectionImpl。
5.4 类比
- 厂商 DataSource = 一家能烧砖的窑(要一块烧一块,不要就砸)
- Hikari/Druid = 建材市场 + 仓库(预订、复用、监控、报警)
- 分工不同,谁都替代不了谁
六、Connection / Statement / ResultSet 的资源占用
6.1 三者不是三个连接,是三层资源
Connection = 一条数据库会话(TCP 连接 + 会话状态)
↓ 派生
Statement = 会话上的一次"SQL 执行上下文"
↓ 派生
ResultSet = SQL 执行返回的"结果集游标"
6.2 三者各占的独立资源(不重复)
| 资源 | Connection | Statement | ResultSet |
|---|:---:|:---:|:---:|
| 客户端 TCP fd | ✅ 独占 1 个 | ❌ | ❌ |
| 客户端 Socket 缓冲区 | ✅ | ❌ | ❌ |
| MySQL THD 线程/会话 | ✅ | ❌ | ❌ |
| MySQL Threads_connected 槽位 | ✅ | ❌ | ❌ |
| MySQL prepared statement 缓存 | ❌ | ✅ 每 PS 一份 | ❌ |
| MySQL statement_id 编号 | ❌ | ✅ 独占 | ❌ |
| MySQL 游标 / 临时表 / 排序缓冲 | ❌ | ❌ | ✅ 独占 |
| MySQL 待发送行内存 | ❌ | ❌ | ✅ |
6.3 数量独立累加
Connection (1 个)
↓ 占:1 fd + 1 THD + 1 连接槽位
│
├── Statement #1 → MySQL:1 份 prepared_stmt + statement_id
│ └── ResultSet A → MySQL:1 份游标/临时表
│ └── ResultSet B → MySQL:1 份游标/临时表
├── Statement #2 → MySQL:1 份 prepared_stmt
│ └── ResultSet C → MySQL:1 份游标/临时表
└── Statement #3 → MySQL:1 份 prepared_stmt
└── ResultSet D → MySQL:1 份游标/临时表
MySQL 端占用 = 1 Connection + N Statement + N×M ResultSet 份不同资源。
6.4 泄漏后果
| 资源 | 泄漏后果 |
|---|---|
| Connection | 打满 max_connections,全站崩;fd 耗尽 |
| Statement | 打满 max_prepared_stmt_count,新 prepare 失败 |
| ResultSet | 服务端临时资源堆积;同 Statement 无法继续执行 |
6.5 连接池下,conn.close() 只是归还池,不能级联释放服务端资源
这是最典型的"连接池 + 资源泄漏"事故根源。Statement 和 ResultSet 必须各自显式 close,不能指望 Connection 帮忙兜底。
6.6 正确姿势:try-with-resources
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) { ... }
} // JVM 反向 close(): rs → ps → conn
6.7 MySQL 侧观察命令
SHOW GLOBAL STATUS LIKE 'Threads_connected'; -- 连接数
SHOW GLOBAL STATUS LIKE 'Prepared_stmt_count'; -- prepared 数
SELECT * FROM performance_schema.prepared_statements_instances;
七、GC 与资源释放
7.1 关键认知:Java 对象 ≠ 它占用的外部资源
Java 堆里 (GC 管):
PreparedStatement 对象 (~500 字节)
↓ 持有引用
Connection 对象 → Socket 对象 → 缓冲区
JVM 堆外 (GC 不管):
├─ OS file descriptor
├─ 内核 TCP 缓冲区
└─ MySQL 服务端 THD / prepared cache / 游标 / 临时表
GC 只回收左边,不知道右边存在。
7.2 为什么不能靠 GC 清理
1. GC 触发条件是堆内存不够 — 而 PS 对象很小,堆压力小,GC 触发不了;但堆外资源已经泄漏成灾
2. GC 时机太晚 — 就算触发,服务早就崩了
3. finalize() 不可靠 — 时机不定,JDK 9 起废弃
4. 引用挂着 GC 收不了 — 池化 Connection 长期存活,挂在下面的 Statement 永远不 GC
7.3 生产事故时间线
依赖 GC 的写法:
- T+15s fd 达到 1024,报
Too many open files - T+30s MySQL
max_prepared_stmt_count打满 - T+10min GC 才动一下
5 秒挂,10 分钟才 GC。指望它救命?没戏。
7.4 正确的心智模型
| | 显式 close() | GC |
|---|---|---|
| 谁负责 | 应用代码 | JVM 后台线程 |
| 何时触发 | 立即 | 堆水位到阈值 |
| 清理什么 | 外部资源(fd/socket/服务端会话) | Java 对象内存 |
| 不做的后果 | 几秒到几分钟服务挂 | 堆 OOM,自动触发 |
两条清理管道,各司其职,谁都替代不了谁。
7.5 反直觉:大量短命对象 JVM 处理得很好
for (100 万次) { try (PS) { ... } } 完全没问题。JVM 最擅长处理短命对象,Young GC 秒回收几百万个。真正伤性能的不是"多创建对象",而是"少量长生命周期对象拉着大量外部资源不放"。
八、分布式场景下的连接数问题
8.1 应用集群 × 池 × 后端实例 → 连接数爆炸
假设:100 台 App × 每机每池 10 条 × 10 个 MySQL 实例。
| 架构 | 每 MySQL 实例连接数 |
|---|---|
| 单池随机路由(罕见) | ~100 |
| 每 MySQL 独立池 × 10(主流) | ~1000 |
| 分片架构,每机只连相关分片 | ~100-200 |
行业规律:App 机器数 × 每机池大小 = MySQL 连接负担,线性放大。
8.2 读写分离救不了 Master 连接数
读写分离拆的是SQL 请求分布,不是连接数。
改造前: 100 App × 30 → Master 3000 条
改造后: 100 App × (20 写 + 10 读×3) → Master 2000 条 + 每 Slave 1000 条
Master 从 3000 降到 2000,但仍数千级别,依然会撞 max_connections。总连接数反而涨了。
因为连接池是按并发峰值预留,不是按 QPS 预留 —— 就算 90% SQL 是读、走了 Slave,写池也不能设太小。
8.3 真正的解法(四招组合)
招 1:缩小应用池 size — HikariCP 官方主张 10-20 就够,连接数越少数据库越快
招 2:引入连接代理层(核心) — ProxySQL / MaxScale / RDS Proxy
App × 1000 条 → ProxySQL → MySQL 只看到 50-100 条(多路复用)
这是分布式系统里唯一真正解决"应用扩张 → DB 连接爆炸"的架构。
招 3:MySQL 8 Thread Pool 插件 — 让连接数 ≠ OS 线程数,缓解上下文切换
招 4:分库分表 — 终极解法,把连接数分散到 N 个 Master
8.4 各扩容手段对应的瓶颈
| 瓶颈 | 主从复制 | 读写分离 | ProxySQL | 分库分表 |
|---|:---:|:---:|:---:|:---:|
| 读 QPS | ✅ | ✅ | 部分 | ✅ |
| 写 QPS | ❌ | ❌ | ❌ | ✅ |
| 数据总量 | ❌ | ❌ | ❌ | ✅ |
| Master 连接数 | ❌ | ❌ | ✅ | ✅ |
| 应对 App 集群扩张 | ❌ | ❌ | ✅ | ✅ |
九、max_connections 与保护上限
9.1 什么是 max_connections
MySQL 允许的最大并发连接数。超过后新连接返回:
ERROR 1040 (HY000): Too many connections
默认 151(MySQL 5.7 / 8.0 都是),生产必然要大调。
9.2 修改方式
SET GLOBAL max_connections = 5000; -- 运行时,重启失效
# /etc/my.cnf
[mysqld]
max_connections = 5000
9.3 大调的代价
- 内存暴涨:每连接 1-2MB × 5000 = 5-10 GB,挤占
innodb_buffer_pool - OS 线程/上下文切换爆炸(MySQL 5.6- 每连接一线程)
- 文件描述符跟着涨(要同步调
open_files_limit和 ulimit) - 雪崩风险不减反增:一个慢 SQL 卡住,上千连接排队等锁
HikariCP 作者名言:"连接数越少,数据库越快"。
9.4 合理范围
| 场景 | 典型值 |
|---|---|
| 单机小应用 | 151 (默认足够) |
| 中等规模 App 集群 | 500 - 2000 |
| 大规模互联网 | 3000 - 8000 |
| 接入 Proxy | MySQL 端可以调小 |
9.5 本质:优雅拒绝 vs 雪崩崩溃
没有 max_connections:
- 内存耗尽 → swap → CPU 100% 上下文切换 → OOM Killer → 全站崩
- 渐变式死机,难恢复
有 max_connections:
- 到达上限直接返回错误
- MySQL 本身不崩,已有连接照常服务
- 应用能明确感知,走降级
- DBA 有时间介入
这是通用的"背压 / 限流 / 舱壁隔离" 模式:
过载时拒绝新请求 > 接受了但处理不完
同款设计到处都是:Nginx worker_connections、Tomcat max-threads、Redis maxclients、K8s memory limit、HikariCP maximumPoolSize。
十、MySQL 水平扩容
10.1 主从复制:只能扩读
Master (写)
├─ binlog → Slave 1 (读)
├─ binlog → Slave 2 (读)
└─ binlog → Slave N (读)
能扩:读 QPS、报表 / OLAP、异地容灾
不能扩:写 QPS、数据总量(每从库都存全量)
上限:从库回放 binlog 有限并行 → 主从延迟 → 读到旧数据 → 只能回读主库
10.2 为什么写不能扩:数据一致性的根本矛盾
多主同写会产生冲突;强一致 + 高性能 + 水平扩展写不可兼得。MySQL 选择"强一致 + 单点写",牺牲写扩展性。
要水平扩写,只能分库分表。
10.3 分库分表:唯一真正的水平扩写
业务读写
↓
分片路由 (按 sharding key)
↓ ↓ ↓ ↓
Shard-0 Shard-1 ... Shard-N
(Master) (Master) (Master)
每个分片独立 Master,只存全量数据的一部分。同时扩了写 QPS、数据量、连接数。
10.4 分库分表的代价
- 分片键选择极难,选错要重构
- 跨分片事务几乎不可能(要 XA / TCC / Saga)
- JOIN 变得困难
- 迁移和扩容痛苦(rehash)
- 全局唯一 ID / 分页 / COUNT(*) / ORDER BY 都要重新设计
- 运维成本几何级增长
能不分就不分,是撑不住之后的选择,不是一上来的选择。
10.5 渐进演进路径
① 单机 MySQL
↓ 读扛不住
② 主从复制 + 读写分离
↓ 主写扛不住 / 数据太大
③ 垂直分库(按业务)
↓ 单业务还扛不住
④ 水平分表
↓
⑤ 水平分库 + 水平分表 ← 本项目在这一阶段
↓ 或
⑥ 分布式数据库(TiDB / OceanBase / PolarDB-X)
10.6 本项目(Ctrip DAL)
App
↓
Ctrip DAL Client(应用层分片路由,不走网络代理)
↓
tomcat-jdbc 池(每分片独立池)
↓
每分片的 MySQL Master (db_cluster)
优势:无额外网络跳、性能最好。
劣势:无跨 App 连接复用,连接数仍为 App 数 × 池大小 × 分片数。
十一、应用规模与容量估算
11.1 App 机器规模参照(2026)
| 规模 | 单服务机器数 | 典型场景 |
|---|---|---|
| 玩具 / 个人项目 | 1-2 台 | 博客、demo |
| 小型创业公司 | 3-20 台 | 早期 SaaS |
| 中型互联网 | 20-200 台 | 垂直行业头部 |
| 中大型 | 200-1000 台 | 用户几千万级 |
| 大厂核心业务 | 1000-10000 台 | 淘宝下单、微信、抖音 |
| 超大规模 | 10000+ 台 | 春晚级 |
100 台:中等规模,不算大型。
11.2 单机 App 处理能力(参照)
| 类型 | 单机 QPS |
|---|---|
| Java 计算密集 | 500 - 2000 |
| Java IO 密集 | 2000 - 5000 |
| Java 纯缓存 | 5000 - 20000 |
| Golang / Rust | 同硬件下 2-3 倍 |
| PHP / Python 同步 | 同硬件下 1/3 - 1/2 |
11.3 容量计算三步
步骤 1:估单机能力(比如 Java Spring Boot ≈ 1500 QPS)
步骤 2:总 QPS / 单机能力
步骤 3:× 1.5-2 倍冗余(单机故障、突发流量、发布滚动、GC 抖动)
11.4 例:读 2w + 写几百 QPS
- 净算:20500 / 1500 ≈ 14 台
- 加冗余:20-30 台
- 缓存做得好 + K8s 弹性伸缩:平峰 5-10 台,高峰 15-20 台
11.5 QPS 量级对应的瓶颈
| QPS | 主要瓶颈 |
|---|---|
| < 1000 | 无 |
| 1k-1w | App 单机吞吐、DB 连接池 |
| 1w-10w | DB 读、缓存命中率、GC 停顿 |
| 10w-100w | DB 写、消息队列、机房带宽 |
| 100w+ | 全组件需要专门优化 |
读 2w + 写几百:处在"App 单机吞吐 + DB 连接"层,不需要分库分表,MySQL 主从 + Redis 缓存足矣。
附录:常见误区速查
| 误区 | 真相 |
|---|---|
| "用了 DataSource 就不用驱动 jar" | ❌ 驱动 jar 必须在 classpath |
| "DriverManager 过时了没人用" | ❌ 很多连接池内部还在用 |
| "HikariDataSource 就是空壳" | ❌ 它内部还嵌了另一个 DataSource 或 Driver |
| "conn.close() 会自动关掉所有 Statement/ResultSet" | ⚠️ 池化场景失效,必须各自 close |
| "对象 GC 时 finalize 会兜底 close" | ❌ 不可靠,已废弃,别指望 |
| "多 new 对象 = 内存爆 = 慢" | ❌ 短命对象是 JVM 最擅长的场景 |
| "读写分离能解决 Master 连接数" | ❌ 只解决 QPS,连接数几乎没变 |
| "调大 max_connections 就能扛更多" | ⚠️ 有代价,内存和线程切换会崩 |
| "只要主从复制就能水平扩容" | ❌ 只扩读,写永远压在单 Master |
| "100 台 App 是大厂规模" | ❌ 大厂核心业务动辄几千上万台 |
附录:关键命令速查
-- 查连接数
SHOW GLOBAL STATUS LIKE 'Threads_connected';
SHOW GLOBAL STATUS LIKE 'Max_used_connections';
SHOW VARIABLES LIKE 'max_connections';
-- 查 prepared statement
SHOW GLOBAL STATUS LIKE 'Prepared_stmt_count';
SELECT * FROM performance_schema.prepared_statements_instances;
-- 查临时表
SHOW STATUS LIKE 'Created_tmp_tables';
SHOW STATUS LIKE 'Created_tmp_disk_tables';
-- 动态调 max_connections
SET GLOBAL max_connections = 5000;
# 查进程 fd 数
ls /proc/<pid>/fd | wc -l
# 查 fd 上限
cat /proc/<pid>/limits | grep "open files"
# 查 socket 数
ls -l /proc/<pid>/fd | grep socket | wc -l