数据库中间件:高并发的"缓冲器"
系列七:基础设施篇 · 第7篇
双十一零点,订单如潮水般涌入。数据库连接数瞬间爆满,查询响应时间从毫秒级飙升到秒级,整个系统像被卡住喉咙一样喘不过气……
这不是危言耸听,而是每个高并发系统都可能面临的挑战。数据库作为系统的核心存储,往往是性能瓶颈的根源。
数据库中间件就是为解决这个问题而生的。它在应用和数据库之间建立一道"缓冲带",通过缓存、连接池、读写分离、分库分表等手段,让数据库能够从容应对高并发压力。
今天我们来聊聊数据库中间件的设计原理,看看它是如何成为高并发的"缓冲器"的。
一、从单机到分布式:数据库架构的演进之路
在深入中间件之前,我们先来回顾一下数据库架构的演进历史。理解这个过程,才能明白为什么需要这些中间件。
1.1 单机时代:简单但脆弱
互联网早期,用户量小,数据量也小。一台 MySQL 服务器就能搞定一切:
┌─────────────┐
│ 应用服务 │
└──────┬──────┘
│
▼
┌─────────────┐
│ MySQL 单机 │
│ 用户表 │
│ 订单表 │
│ 商品表 │
└─────────────┘
这个阶段的特点:
- 架构简单:一个连接字符串,一切搞定
- 运维轻松:一台机器,出了问题好排查
- 扩展性差:用户量一上来,单机扛不住
就像一个小卖部,老板一个人又能收银又能进货。但顾客多了,就忙不过来了。
1.2 主从复制:读写分离的起点
当用户量增长,单机的读压力越来越大。聪明的人们想到了一个办法:复制一份数据,专门用来读。
这就是主从复制:
写请求
│
▼
┌───────────────┐
│ 主库 Master │
│ 接收所有写入 │
└───────┬───────┘
│
│ binlog 同步
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌───────────────┐┌───────────────┐┌───────────────┐
│ 从库 Slave1 ││ 从库 Slave2 ││ 从库 Slave3 │
│ 承担读请求 ││ 承担读请求 ││ 承担读请求 │
└───────────────┘└───────────────┘└───────────────┘
▲ ▲ ▲
└──────────────┼──────────────┘
读请求
MySQL 的主从复制基于 binlog(二进制日志):
- 主库写入 binlog:主库执行写操作后,将变更记录到 binlog
- 从库拉取 binlog:从库的 IO 线程连接主库,读取 binlog 并写入本地的 relay log
- 从库重放 relay log:从库的 SQL 线程读取 relay log,执行其中的 SQL 语句
这是一个异步过程,意味着从库的数据可能会落后于主库。
- 写入 QPS:5000(主要是创建订单、更新状态)
- 读取 QPS:50000(订单详情、订单列表、统计查询)
- 读写比例:10:1
采用 1 主 5 从的架构:
- 主库:32 核 128G 内存,处理所有写入
- 从库:5 台 16 核 64G 内存,分担读取压力
- 每台从库承担约 10000 QPS 的读请求
1.3 分库分表:突破单机极限
当数据量达到十亿级别,或者写入 QPS 超过单机极限,主从复制也不够了。这时候需要分库分表——把数据拆散到多个数据库实例上。
应用服务
│
▼
┌───────────────┐
│ 分库分表 │
│ 中间件 │
└───────┬───────┘
│
┌─────────┬───────┼───────┬─────────┐
▼ ▼ ▼ ▼ ▼
┌─────────┐┌─────────┐┌─────────┐┌─────────┐┌─────────┐
│ 分库 0 ││ 分库 1 ││ 分库 2 ││ 分库 3 ││ 分库 4 │
│用户0-2亿││用户2-4亿││用户4-6亿││用户6-8亿││用户8-10亿│
└─────────┘└─────────┘└─────────┘└─────────┘└─────────┘
| 数据量 | 写入 QPS | 推荐方案 |
|---|---|---|
| < 1000 万 | < 1000 | 单机 + 缓存 |
| 1000 万 - 1 亿 | 1000 - 5000 | 主从复制 + 读写分离 |
| 1 亿 - 10 亿 | 5000 - 20000 | 分库分表(4-16 个分片) |
| > 10 亿 | > 20000 | 分库分表 + 多级缓存 + 异步处理 |
1.4 新一代架构:云原生数据库
最近几年,云厂商推出了新一代的分布式数据库,如 TiDB、OceanBase、Aurora 等。它们把分库分表的能力内置到数据库内核中,应用层不再需要关心数据如何分布。
这是数据库架构的未来方向,但对于大多数公司来说,传统的分库分表方案仍然是主流选择——因为它更可控、成本更低。
二、Redis 缓存设计:用内存换时间
缓存是性价比最高的性能优化手段。用好缓存,往往能起到"四两拨千斤"的效果。
2.1 缓存的基本原理
请求数据 → 查缓存 → 命中? → 是 → 返回缓存数据
↓
否
↓
查数据库 → 写入缓存 → 返回数据
缓存的性能收益 = 命中率 × (数据库查询时间 - 缓存查询时间)
命中率是关键。命中率越高,收益越大。
- 用户信息查询 QPS:100000
- 数据库查询耗时:5ms
- Redis 查询耗时:0.5ms
- 缓存命中率:95%
- 无缓存时:100000 × 5ms = 500 秒的数据库查询时间
- 有缓存时:95000 × 0.5ms + 5000 × 5ms = 47.5 + 25 = 72.5 秒
- 性能提升:6.9 倍
2.2 缓存策略详解
最常用的缓存策略:
- 读:先查缓存,未命中则查数据库,然后写入缓存
- 写:先更新数据库,然后删除缓存
为什么是删除缓存而不是更新缓存?
- 并发写时,更新缓存可能导致数据不一致
- 删除更简单,下次读取时自然会重建
- 如果缓存计算复杂,更新可能浪费资源
// 读取数据
public User getUser(Long userId) {
String cacheKey = "user:" + userId;
String cachedValue = redis.get(cacheKey);
if (cachedValue != null) {
return JSON.parseObject(cachedValue, User.class);
}
// 缓存未命中,查数据库
User user = userMapper.selectById(userId);
if (user != null) {
// 写入缓存,过期时间 1 小时
redis.setex(cacheKey, 3600, JSON.toJSONString(user));
}
return user;
}
// 更新数据
public void updateUser(User user) {
// 1. 先更新数据库
userMapper.updateById(user);
// 2. 再删除缓存
redis.del("user:" + user.getId());
}
缓存层作为数据访问的唯一入口:
- 应用只和缓存交互
- 缓存负责和数据库同步
- 对应用透明,但缓存层逻辑更复杂
写操作只写缓存,异步批量写入数据库:
- 写性能极高
- 但可能丢失数据
- 适合对数据一致性要求不高的场景
2.3 缓存穿透、击穿、雪崩
这是缓存设计的三大经典问题。
问题:查询一个不存在的数据,缓存没有,数据库也没有。每次请求都打到数据库。
场景:恶意攻击,大量请求不存在的 ID。
解决方案:
- 布隆过滤器:快速判断数据是否可能存在
- 缓存空值:将空结果也缓存起来,设置较短的过期时间
// 初始化布隆过滤器
public void initBloomFilter() {
// 预计元素数量 1 亿,误判率 0.01%
BloomFilter<Long> bloomFilter = BloomFilter.create(
Funnels.longFunnel(),
100000000,
0.0001
);
// 加载所有用户 ID
List<Long> allUserIds = userMapper.selectAllIds();
for (Long userId : allUserIds) {
bloomFilter.put(userId);
}
}
// 查询时先检查布隆过滤器
public User getUser(Long userId) {
// 布隆过滤器判断不存在,直接返回
if (!bloomFilter.mightContain(userId)) {
return null;
}
// 正常的缓存查询逻辑
return getUserFromCache(userId);
}
问题:某个热点数据过期,大量请求同时查询,全部打到数据库。
场景:热门商品缓存刚好过期,瞬间大量请求涌入。
解决方案:
- 互斥锁:只允许一个请求重建缓存
- 逻辑过期:不设置 TTL,由后台任务更新
- 永不过期:热点数据不设过期时间,由业务主动更新
public User getUserWithLock(Long userId) {
String cacheKey = "user:" + userId;
String cachedValue = redis.get(cacheKey);
if (cachedValue != null) {
return JSON.parseObject(cachedValue, User.class);
}
// 尝试获取分布式锁
String lockKey = "lock:user:" + userId;
boolean locked = redis.setnx(lockKey, "1", 10); // 10 秒超时
if (locked) {
try {
// 获取锁成功,查询数据库并重建缓存
User user = userMapper.selectById(userId);
if (user != null) {
redis.setex(cacheKey, 3600, JSON.toJSONString(user));
}
return user;
} finally {
redis.del(lockKey);
}
} else {
// 获取锁失败,等待后重试
Thread.sleep(50);
return getUserWithLock(userId);
}
}
问题:大量缓存同时过期,或者缓存服务宕机,所有请求打到数据库。
场景:缓存预热时设置了相同的过期时间。
解决方案:
- 过期时间加随机值:避免同时过期
- 缓存高可用部署:避免单点故障
- 限流降级:保护数据库不被打垮
// 基础过期时间 1 小时,加上随机 0-10 分钟
int baseExpire = 3600;
int randomExpire = ThreadLocalRandom.current().nextInt(0, 600);
redis.setex(cacheKey, baseExpire + randomExpire, value);
2.4 缓存的数据一致性
缓存和数据库的数据一致性是永恒的话题。
- 强一致性:读到的数据一定是最新的
- 最终一致性:短期内可能读到旧数据,但最终会一致
大多数互联网场景选择最终一致性,因为强一致性的代价太高。
更新数据库后,延迟一段时间再删除一次缓存:
1. 删除缓存
2. 更新数据库
3. 延迟 N 毫秒
4. 再次删除缓存
目的是解决并发场景下,旧数据被写入缓存的问题。
public void updateUser(User user) {
String cacheKey = "user:" + user.getId();
// 1. 先删除缓存
redis.del(cacheKey);
// 2. 更新数据库
userMapper.updateById(user);
// 3. 延迟后再删除(异步执行)
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(500); // 延迟 500ms
redis.del(cacheKey);
} catch (InterruptedException e) {
log.error("延迟双删失败", e);
}
});
}
通过订阅数据库的 binlog,异步更新/删除缓存:
- 完全解耦业务代码和缓存操作
- 保证最终一致性
- 需要额外维护 Canal 组件
┌─────────┐ 写入 ┌─────────┐
│ 应用 │ ──────────────→│ MySQL │
└─────────┘ └────┬────┘
│
│ binlog
▼
┌─────────┐
│ Canal │
│ Server │
└────┬────┘
│
│ 消息
▼
┌─────────┐
│ 消费者 │
│ 更新缓存 │
└────┬────┘
│
▼
┌─────────┐
│ Redis │
└─────────┘
2.5 Redis 集群部署
最简单的 Redis 高可用方案:
- 一个主节点负责写
- 多个从节点负责读
- 主节点故障需要手动切换
在主从基础上增加哨兵节点:
- 监控主从节点状态
- 自动故障转移
- 配置提供者,告诉客户端当前的主节点
Redis 官方的分布式方案:
- 数据自动分片到多个节点
- 每个节点负责一部分槽位(共 16384 个槽)
- 支持自动故障转移
- 真正的分布式和高可用
- 至少 3 个主节点
- 每个主节点至少 1 个从节点
- 推荐配置:3 主 3 从,共 6 个节点
三、数据库连接池:复用连接的艺术
连接池是最基础也是最有效的数据库优化手段之一。
3.1 为什么需要连接池
建立一条数据库连接需要:
- TCP 三次握手
- MySQL 握手和认证
- 分配连接内存和线程资源
一次连接建立可能需要几十毫秒。如果每个请求都新建连接,高并发时连接建立本身就成了瓶颈。
| 操作 | 耗时 |
|---|---|
| 新建连接 + 简单查询 + 关闭 | 3-5ms |
| 从连接池获取 + 简单查询 + 归还 | 0.5-1ms |
连接池预先建立一批连接,请求来时直接从池中获取,用完后归还:
- 避免频繁建立/断开连接
- 连接可以长期保持,复用数千次
- 控制连接总数,保护数据库
3.2 连接池的核心参数
- 池中长期保持的连接数量
- 太小:突发流量时需要新建连接
- 太大:占用不必要的资源
- 池中允许的最大连接数量
- 这是保护数据库的关键参数
- 需要根据数据库连接数上限和应用实例数计算
- 获取连接的最大等待时间
- 超时后抛出异常,快速失败
- 避免请求无限等待
- 连接空闲超过此时间后被回收
- 动态调整连接池大小
- 太短会频繁创建连接,太长占用资源
- 连接的最大存活时间
- 防止长时间使用的连接出现问题
- 建议略小于数据库的 wait_timeout
3.3 连接池参数调优实战
- 数据库总连接数预算:
- 预留连接给 DBA、监控等:200 - 可用连接数:1800
- 单个应用实例的最大连接数:
- 留出安全余量:15
- HikariCP 配置:
spring:
datasource:
hikari:
maximum-pool-size: 15
minimum-idle: 5
connection-timeout: 3000 # 3 秒
idle-timeout: 600000 # 10 分钟
max-lifetime: 1800000 # 30 分钟
connection-test-query: SELECT 1
| 配置 | QPS | 平均响应时间 | 错误率 |
|---|---|---|---|
| 无连接池 | 500 | 50ms | 5% |
| 连接池(max=10) | 2000 | 15ms | 0.1% |
| 连接池(max=20) | 3500 | 12ms | 0.05% |
| 连接池(max=50) | 3500 | 15ms | 0.1% |
结论:连接数不是越多越好,超过一定阈值反而会增加竞争。
3.4 主流连接池对比
目前最流行的 Java 连接池:
- 性能极高,号称"零开销"抽象
- 代码精简,稳定可靠
- Spring Boot 默认选择
阿里巴巴开源的连接池:
- 功能丰富,内置监控和 SQL 统计
- 防火墙功能,可拦截危险 SQL
- 配置灵活,但性能略逊于 HikariCP
- 追求极致性能:HikariCP
- 需要监控和 SQL 分析:Druid
- 实际上两者差距不大,更多是习惯问题
四、读写分离:分散读压力
当读请求远多于写请求时,读写分离是最有效的扩展手段。
4.1 读写分离的架构设计
写请求
│
▼
┌─────────┐
│ 主库 │ ────┐
│ (Master)│ │ 主从同步
└─────────┘ │
│ │
▼ ▼
┌─────────┐ ┌─────────┐
│ 从库 A │ │ 从库 B │
│ (Slave) │ │ (Slave) │
└─────────┘ └─────────┘
▲ ▲
│ │
└─────────┘
读请求
- 主库:处理所有写操作(INSERT、UPDATE、DELETE)
- 从库:处理读操作(SELECT),可以部署多个
[mysqld]
server-id = 1
log-bin = mysql-bin
binlog-format = ROW
binlog-row-image = FULL
expire_logs_days = 7
[mysqld]
server-id = 2
relay-log = relay-bin
read-only = 1
-- 在主库执行
CREATE USER 'repl'@'%' IDENTIFIED BY 'password';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
FLUSH PRIVILEGES;
-- 在从库执行
CHANGE MASTER TO
MASTER_HOST = '主库IP',
MASTER_USER = 'repl',
MASTER_PASSWORD = 'password',
MASTER_LOG_FILE = 'mysql-bin.000001',
MASTER_LOG_POS = 154;
START SLAVE;
4.2 主从延迟的问题与解决方案
- 网络传输延迟
- 从库执行 relay log 需要时间
- 大事务会显著增加延迟
- 从库性能不足或负载过高
-- 在从库执行
SHOW SLAVE STATUS\G
-- 关键指标
Seconds_Behind_Master: 0 -- 延迟秒数,0 表示无延迟
经典的"写后读"问题:
- 用户提交订单(写主库)
- 立即查询订单详情(读从库)
- 从库还没同步到最新数据 → 查不到订单
对于必须读到最新数据的场景,强制走主库:
// 使用注解标记
@ForceMaster
public Order getMyOrder(Long orderId) {
return orderMapper.selectById(orderId);
}
中间件检测主从延迟,延迟过大时自动切换到主库读取:
public Order getOrder(Long orderId) {
// 检测主从延迟
int delay = replicationMonitor.getDelay();
if (delay > 1000) { // 延迟超过 1 秒
return orderMapper.selectByIdFromMaster(orderId);
} else {
return orderMapper.selectById(orderId);
}
}
接受短期的数据不一致,通过前端交互来规避:
- 写操作后提示"处理中"
- 延迟几秒后再查询
- 通过消息通知用户结果
4.3 读写分离中间件
Apache 基金会的分布式数据库中间件:
- 支持读写分离、分库分表
- 支持多种数据库
- 配置灵活,功能强大
# application.yml
spring:
shardingsphere:
datasource:
names: master,slave0,slave1
master:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://master:3306/mydb
username: root
password: password
slave0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://slave0:3306/mydb
username: root
password: password
slave1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://slave1:3306/mydb
username: root
password: password
rules:
readwrite-splitting:
data-sources:
myds:
write-data-source-name: master
read-data-source-names: slave0,slave1
load-balancer-name: round_robin
load-balancers:
round_robin:
type: ROUND_ROBIN
4.4 多从库的负载均衡
当有多个从库时,读请求如何分配?
依次分配请求到各从库。简单公平。
根据从库的性能分配不同权重。性能好的从库承担更多请求。
将请求分配给当前连接数最少的从库。动态均衡,但需要维护连接状态。
同一用户的请求总是路由到同一从库。可以利用从库的本地缓存。
五、分库分表:终极扩展方案
当单机数据库无法承载业务规模时,分库分表是最后的手段,也是最复杂的方案。
5.1 分库分表的场景判断
- 单表数据量超过 5000 万
- 数据库 QPS 超过 10000
- 单机磁盘空间不足
- 慢查询比例持续升高
- 每日新增流水:5000 万条
- 单表数据量:18 亿条
- 查询延迟:5-30 秒
- 决定:按时间分表,按用户 ID 分库
5.2 分库 vs 分表
在同一个数据库实例内,将大表拆成多个小表:
- 解决单表数据量过大的问题
- 不解决单机性能瓶颈
- 实现简单,适合中等规模场景
将数据分散到多个数据库实例,每个实例都有完整的表结构:
- 解决单机性能瓶颈
- 单表数据量没有减少
- 适合数据量中等但并发量高的场景
最彻底的方案:
- 数据分散到多个实例
- 每个实例内的表也拆分
- 突破单表和单机的双重限制
- 复杂度最高
5.3 分片策略详解
按数据范围分片,如按时间、按 ID 范围:
-- 按用户 ID 范围分库
用户 ID 1-1000 万 → db0
用户 ID 1000-2000 万 → db1
用户 ID 2000-3000 万 → db2
优点:范围查询效率高,扩容简单 缺点:数据可能分布不均匀,热点问题
对分片键进行哈希运算,然后取模:
库序号 = hash(user_id) % 库数量
表序号 = hash(user_id) / 库数量 % 表数量
优点:数据分布均匀 缺点:扩容困难,需要数据迁移;范围查询需要扫描所有分片
- 总数据量:10 亿订单
- 分片策略:按用户 ID 哈希
- 分片配置:16 库 × 16 表 = 256 个分片
- 每个分片约 400 万数据
public String getTableName(Long userId) {
int hash = userId.hashCode();
int dbIndex = Math.abs(hash) % 16;
int tableIndex = Math.abs(hash / 16) % 16;
return "order_db_" + dbIndex + ".order_" + tableIndex;
}
将哈希值映射到环上,数据落在顺时针方向的第一个节点:
优点:扩容时只需迁移部分数据 缺点:实现复杂,需要引入虚拟节点保证均匀
5.4 分片键的选择
分片键决定了数据如何分布,选择至关重要。
- 数据分布均匀:避免热点
- 查询效率高:大部分查询能定位到具体分片
- 业务关联性强:相关数据在同一分片,避免跨分片事务
按用户 ID 分片后,如何查询"某个商品的所有订单"?
解决方案:
订单表按用户 ID 分片,另建索引表按商品 ID 分片:
主表:order(按 user_id 分片)
索引表:order_product_index(按 product_id 分片)
写入时按用户 ID 分片,异步任务构建商品维度的索引:
// 写入订单
public void createOrder(Order order) {
// 1. 写入主表
orderMapper.insert(order);
// 2. 发送消息,异步构建索引
mq.send("order.index", order);
}
// 异步消费者
@Consumer(topic = "order.index")
public void buildIndex(Order order) {
OrderProductIndex index = new OrderProductIndex();
index.setProductId(order.getProductId());
index.setOrderId(order.getId());
index.setUserId(order.getUserId());
orderProductIndexMapper.insert(index);
}
将数据同步到 Elasticsearch,支持任意维度查询:
写入流程:MySQL → Canal → Kafka → Elasticsearch
查询流程:ES 查询得到订单 ID → MySQL 查询详情
5.5 分布式事务解决方案
分库分表后,跨库事务成为难题。
经典的分布式事务方案:
- 准备阶段:协调者通知所有参与者准备提交
- 提交阶段:所有参与者都准备好后,协调者通知提交
缺点:同步阻塞,性能差;协调者单点故障风险
阿里开源的分布式事务框架,对业务无侵入:
@GlobalTransactional
public void createOrder(OrderDTO orderDTO) {
// 扣减库存(库存服务)
inventoryService.deduct(orderDTO.getProductId(), orderDTO.getQuantity());
// 创建订单(订单服务)
orderService.create(orderDTO);
// 扣减余额(账户服务)
accountService.deduct(orderDTO.getUserId(), orderDTO.getAmount());
}
Seata 会自动管理这些跨服务的操作,保证事务一致性。
将跨库操作记录到本地事务表,通过消息队列异步执行:
// 创建订单时,同时写入本地消息表
@Transactional
public void createOrder(Order order) {
// 1. 写入订单
orderMapper.insert(order);
// 2. 写入本地消息表
LocalMessage message = new LocalMessage();
message.setBizType("ORDER_CREATED");
message.setBizId(order.getId());
message.setStatus("PENDING");
localMessageMapper.insert(message);
}
// 定时任务扫描并发送消息
@Scheduled(fixedDelay = 1000)
public void sendPendingMessages() {
List<LocalMessage> messages = localMessageMapper.selectPending();
for (LocalMessage message : messages) {
try {
mq.send(message);
localMessageMapper.updateStatus(message.getId(), "SENT");
} catch (Exception e) {
log.error("发送消息失败", e);
}
}
}
5.6 全局唯一 ID 生成
分库分表后,数据库自增 ID 无法保证全局唯一。
Twitter 开源的分布式 ID 生成算法:
0 - 41位时间戳 - 10位机器ID - 12位序列号
总共 64 位:
- 符号位:1 位,始终为 0
- 时间戳:41 位,毫秒级,可用约 69 年
- 机器 ID:10 位,最多 1024 台机器
- 序列号:12 位,每毫秒最多 4096 个 ID
public class SnowflakeIdGenerator {
private final long workerId;
private final long datacenterId;
private long sequence = 0;
private long lastTimestamp = -1L;
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨");
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & 4095;
if (sequence == 0) {
timestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0;
}
lastTimestamp = timestamp;
return ((timestamp - 1288834974657L) << 22)
<table>
<thead><tr>
</tr></thead><tbody>
<thead><tr>
</tr></thead><tbody>
<thead><tr>
</tr></thead><tbody>
</tbody></table>
}
}
美团开源的分布式 ID 生成系统:
- 从数据库批量获取 ID 号段
- 本地分配,减少数据库压力
- 支持双 buffer 预加载,保证高可用
六、数据库性能优化技巧
除了架构层面的优化,还有很多实用的数据库性能优化技巧。
6.1 慢查询优化
-- 开启慢查询日志
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1; -- 超过 1 秒记录
-- 查看慢查询
SELECT * FROM mysql.slow_log ORDER BY start_time DESC LIMIT 10;
EXPLAIN SELECT * FROM orders WHERE user_id = 12345;
关键指标:
- type:ALL 表示全表扫描,需要优化
- key:使用的索引
- rows:预估扫描行数
- Extra:Using filesort 或 Using temporary 需要关注
- 添加合适的索引
- 避免 SELECT *
- 避免在 WHERE 子句中对字段进行函数运算
- 使用 LIMIT 限制返回行数
- 避免大 OFFSET 分页
-- 慢:需要扫描前 10000 行
SELECT * FROM orders ORDER BY id LIMIT 10000, 20;
-- 快:直接定位到起始位置
SELECT * FROM orders WHERE id > 10000 ORDER BY id LIMIT 20;
6.2 索引优化
- 最左前缀原则:复合索引从左到右匹配
- 选择性高的列优先:区分度高的列放在前面
- 覆盖索引:查询字段都在索引中,避免回表
-- 索引
CREATE INDEX idx_user_status_time ON orders(user_id, status, create_time);
-- 能命中索引
SELECT * FROM orders WHERE user_id = 123;
SELECT * FROM orders WHERE user_id = 123 AND status = 'PAID';
SELECT * FROM orders WHERE user_id = 123 AND status = 'PAID' AND create_time > '2024-01-01';
-- 不能命中索引
SELECT * FROM orders WHERE status = 'PAID';
SELECT * FROM orders WHERE create_time > '2024-01-01';
6.3 连接数优化
- 应用报错:Too many connections
- 数据库 CPU 飙升
- 响应时间变长
-- 查看当前连接数
SHOW STATUS LIKE 'Threads_connected';
-- 查看最大连接数
SHOW VARIABLES LIKE 'max_connections';
-- 查看连接详情
SHOW PROCESSLIST;
-- 查看哪些用户占用连接多
SELECT USER, COUNT(*) as conn_count
FROM information_schema.PROCESSLIST
GROUP BY USER
ORDER BY conn_count DESC;
- 调整 max_connections 参数
- 优化连接池配置
- 排查连接泄漏
- 使用连接池监控
6.4 内存优化
InnoDB 的核心缓存区域,缓存数据和索引:
-- 查看 Buffer Pool 大小
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
-- 查看 Buffer Pool 命中率
SHOW STATUS LIKE 'Innodb_buffer_pool_read%';
- Buffer Pool 应该是可用内存的 60%-80%
- 确保热点数据都能缓存在内存中
6.5 磁盘 I/O 优化
SSD 的随机 I/O 性能远超机械硬盘:
- 机械硬盘 IOPS:100-200
- SATA SSD IOPS:10000-50000
- NVMe SSD IOPS:100000-500000
-- 查看当前配置
SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit';
-- 0:每秒刷盘,性能最好但可能丢数据
-- 1:每次事务提交刷盘,最安全但性能差
-- 2:每次提交写入 OS 缓存,每秒刷盘
七、总结与最佳实践
数据库中间件是高并发系统的"缓冲器"和"减压阀"。它在应用和数据库之间建立了多层保护,让数据库能够稳定、高效地运行。
7.1 核心手段回顾
| 手段 | 解决的问题 | 代价 | 适用场景 |
|---|---|---|---|
| Redis 缓存 | 读压力大 | 数据一致性、缓存维护 | 读多写少 |
| 连接池 | 连接开销大 | 参数调优 | 所有场景 |
| 读写分离 | 读压力大、单机瓶颈 | 主从延迟、路由复杂性 | 读远多于写 |
| 分库分表 | 数据量大、写压力大 | 分布式事务、查询复杂性 | 数据量超亿级 |
7.2 架构演进路线
- 优化慢查询和索引
- 添加缓存(Redis)
- 使用连接池
- 读多写少场景
- 1 主 2-5 从
- 应用层或中间件实现读写分离
- 数据量超 5000 万
- 写入 QPS 超万级
- 引入分布式事务方案
- TiDB、OceanBase 等
- 自动分片、分布式事务
- 运维成本更低
7.3 设计原则
- 先优化慢查询和索引:大多数性能问题源于糟糕的 SQL
- 先用缓存:性价比最高,立竿见影
- 再读写分离:读多写少的场景效果明显
- 最后分库分表:不到万不得已不要走这一步,复杂度太高
7.4 避坑指南
- 缓存穿透要用布隆过滤器
- 缓存击穿要用互斥锁
- 缓存雪崩要加随机过期时间
- 最大连接数不是越大越好
- 注意连接泄漏问题
- 监控连接池使用率
- 关注主从延迟
- 关键读操作走主库
- 监控从库负载
- 分片键选择要慎重
- 提前规划分片数量
- 跨分片查询要有方案
7.5 监控指标
- QPS/TPS
- 慢查询数量和比例
- 连接数使用率
- 主从延迟
- 缓存命中率
- 内存使用率
- 连接数
- 活跃连接数
- 等待获取连接的请求数
- 连接获取平均时间
数据库中间件不是银弹,它解决问题但也带来复杂性。选择合适的方案,在性能和复杂度之间找到平衡,才是架构师的真正功力。
记住一句话:能用简单方案解决的,就不要用复杂方案。过早优化是万恶之源。
- 系列七 · 第1篇:服务注册与发现
- 系列七 · 第2篇:负载均衡
- 系列七 · 第3篇:配置中心
- 系列七 · 第4篇:日志系统
- 系列七 · 第5篇:HTTP 客户端
- 系列七 · 第6篇:监控告警
- 系列七 · 第7篇:数据库中间件(本文)
💬 评论 (0)