说实话,第一次看到”千万级查询”这几个字的时候,很多人脑子里第一反应是:“这数据库得得多大?”、“MySQL能扛住吗?”、“是不是得上分布式数据库?”
别急。
其实,京东618背后那一套东西,并没有你想象中那么玄乎。它不是靠堆机器,而是靠架构设计。今天,我就把这套从“单库死扛”到“分库分表+读写分离+连接池优化”的完整落地方案,掰开揉碎讲给你听。
咱们不聊虚的,直接看真实场景、真实痛点、真实代码。
一、先搞清楚:为什么MySQL会崩?
在讲解决方案之前,我们得先知道敌人是谁。
想象一下,618当天,几百万人同时抢购一款手机。你的订单表、库存表、用户表,全都挤在一个MySQL实例上。
崩盘的三个根源:
- 连接数爆炸:每个请求都要建立数据库连接,高并发下连接池瞬间被耗尽,新请求排队,甚至直接OOM(内存溢出)。
- 磁盘I/O瓶颈:单库单表数据量太大,每次查询都要扫大量数据,磁盘读写成为瓶颈。
- 主从延迟:读写分离后,从库数据同步滞后,用户刚下单,查到的库存还是旧的,导致超卖。
所以,解决思路很清晰:
- 分库分表 → 解决数据量问题和单点性能问题
- 读写分离 → 解决写压力集中问题
- 连接池优化 → 解决连接数爆炸问题
二、分库分表:把大象放进冰箱,分四步走
2.1 什么是分库分表?
简单说,就是把一个大表,拆成多个小表,分散在多个数据库里。
比如:
- 订单表有10亿条数据,单表查询慢如蜗牛。
- 我们把它拆成16个库,每个库16张表,总共256张表。
- 通过
order_id取模,把数据均匀分布到不同分片。
2.2 分库分表的核心:Sharding Key(分片键)的选择
这是最关键的一步。选错了分片键,后面全是坑。
常见错误选法:
- 用
user_id分片,但查询条件是order_time,导致全库扫描。 - 用
status分片,数据倾斜严重,有的分片数据量是其他的10倍。
正确选法:
- 高频查询字段必须作为分片键,或者能通过分片键定位到具体分片。
- 数据分布尽量均匀,避免热点数据集中在一个分片。
2.3 实战:用ShardingSphere做分库分表
京东内部用的是自研框架,但开源社区最成熟的是Apache ShardingSphere。我们用Spring Boot + ShardingSphere-JDBC来演示。
第一步:引入依赖
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.2</version>
</dependency>
<!-- MySQL驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.28</version>
</dependency>
<!-- ShardingSphere-JDBC -->
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
<version>5.2.0</version>
</dependency>
</dependencies>
第二步:配置文件application.yml
server:
port: 8080
spring:
shardingsphere:
datasource:
names: ds0,ds1,ds2,ds3 # 4个数据库,模拟分库
ds0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/order_db_0?useSSL=false&serverTimezone=UTC
username: root
password: root
hikari:
maximum-pool-size: 50 # 每个库最大连接数
minimum-idle: 10
connection-timeout: 30000
ds1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3307/order_db_1?useSSL=false&serverTimezone=UTC
username: root
password: root
hikari:
maximum-pool-size: 50
minimum-idle: 10
connection-timeout: 30000
ds2:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3308/order_db_2?useSSL=false&serverTimezone=UTC
username: root
password: root
hikari:
maximum-pool-size: 50
minimum-idle: 10
connection-timeout: 30000
ds3:
type: com.zaxxer.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3309/order_db_3?useSSL=false&serverTimezone=UTC
username: root
password: root
hikari:
maximum-pool-size: 50
minimum-idle: 10
connection-timeout: 30000
rules:
sharding:
tables:
t_order: # 逻辑表名
actual-data-nodes: ds$->{0..3}.t_order_$->{0..7} # 4库16表,共64张物理表
table-strategy:
standard:
sharding-column: order_id # 分片键
sharding-algorithm-name: order-table-inline
key-generate-strategy:
column: order_id
key-generator-name: snowflake # 分布式主键
sharding-algorithms:
order-table-inline:
type: INLINE
props:
algorithm-expression: t_order_$->{order_id % 16} # 按order_id取模分表
key-generators:
snowflake:
type: SNOWFLAKE
props:
worker-id: 1 # 每台机器唯一ID
props:
sql-show: true # 打印SQL,方便调试
第三步:实体类和Mapper
@Data
public class Order {
private Long orderId;
private Long userId;
private Integer amount;
private Integer status;
private Date createTime;
}
@Mapper
public interface OrderMapper {
@Insert("INSERT INTO t_order (order_id, user_id, amount, status, create_time) VALUES (#{orderId}, #{userId}, #{amount}, #{status}, #{createTime})")
int insert(Order order);
@Select("SELECT * FROM t_order WHERE order_id = #{orderId}")
Order selectById(@Param("orderId") Long orderId);
@Select("SELECT * FROM t_order WHERE user_id = #{userId}")
List<Order> selectByUserId(@Param("userId") Long userId);
}
第四步:Service层
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
/**
* 创建订单
*/
public Order createOrder(Long userId, Integer amount) {
Order order = new Order();
order.setOrderId(SnowflakeIdGenerator.nextId()); // 分布式ID生成
order.setUserId(userId);
order.setAmount(amount);
order.setStatus(0); // 待支付
order.setCreateTime(new Date());
orderMapper.insert(order);
return order;
}
/**
* 查询订单
*/
public Order getOrder(Long orderId) {
return orderMapper.selectById(orderId);
}
}
关键点解释:
algorithm-expression: t_order_$->{order_id % 16}- 这是分表规则,
order_id对16取模,结果就是表后缀。 - 比如
order_id=100,落在t_order_4(100%16=4)。
- 这是分表规则,
key-generate-strategy- 用雪花算法(Snowflake)生成全局唯一ID,避免分布式环境下的主键冲突。
sql-show: true- 生产环境要关闭,但开发调试时很有用,能看到实际路由到哪张表。
三、读写分离:让主库专心写,从库负责读
分库分表之后,写压力分散了,但读压力呢?618期间,大部分请求都是查询(查订单、查库存),写操作其实只占一小部分。
读写分离的核心思想:
- 写操作 → 主库(Master)
- 读操作 → 从库(Slave)
3.1 配置读写分离
在上面的application.yml基础上,加一行配置:
spring:
shardingsphere:
rules:
readwrite-splitting:
data-sources:
ds: # 逻辑数据源
write-data-source-name: ds0 # 主库
read-data-source-names: ds1,ds2,ds3 # 从库
loaders:
- name: round-robin
type: ROUND_ROBIN # 负载均衡策略:轮询
props:
max-retries: 1
timeout: 1000
这样,ShardingSphere会自动把写路由到ds0,读路由到ds1/ds2/ds3。
3.2 从库延迟怎么办?
这是读写分离最大的痛点。主库写完,数据同步到从库需要时间(通常几十毫秒到几秒)。用户刚下单,立马查订单,可能查到旧数据。
解决方案:
强制路由主库(针对写后读场景)
@Service public class OrderService { @Autowired private OrderMapper orderMapper; public Order createOrderAndQuery(Long userId, Integer amount) { // 创建订单(写主库) Order order = createOrder(userId, amount); // 强制路由到主库查询(避免查到旧数据) ShardingSphereTransactionContextManager .getTransactionContext() .getTransactionListener() .onCommit(); // 这里用强制主库查询的API return orderMapper.selectById(order.getOrderId()); } }ShardingSphere提供了
HintManager来强制路由:HintManager hintManager = HintManager.getInstance(); hintManager.setMasterRouteOnly(); // 强制走主库 Order order = orderMapper.selectById(orderId); hintManager.close();业务层补偿:不强制主库,但告诉用户“数据同步中,请稍后刷新”。
MQ异步通知:订单创建后,发消息到MQ,消费者更新缓存,前端轮询缓存。
四、连接池优化:别让连接数成为瓶颈
前面配置里,每个库的maximum-pool-size: 50,4个库就是200个连接。如果QPS是1万,每个连接平均处理50ms,需要10000 * 0.05 = 500个连接,200个根本不够。
4.1 连接池参数调优
HikariCP参数详解:
hikari:
maximum-pool-size: 100 # 最大连接数,根据QPS和响应时间计算
minimum-idle: 20 # 最小空闲连接数
idle-timeout: 600000 # 空闲连接超时时间(10分钟)
max-lifetime: 1800000 # 连接最大生命周期(30分钟),避免数据库端超时
connection-timeout: 30000 # 获取连接超时时间
leak-detection-threshold: 60000 # 连接泄漏检测(60秒未归还报警)
pool-name: OrderHikariPool
计算公式:
最大连接数 = (QPS * 平均响应时间) / 1000
例如:QPS=10000,响应时间=50ms
连接数 = 10000 * 50 / 1000 = 500
4.2 连接池监控
ShardingSphere提供了Metrics模块,可以监控连接池状态:
@Configuration
public class MetricsConfig {
@Bean
public MeterRegistry meterRegistry() {
SimpleMeterRegistry registry = new SimpleMeterRegistry();
// 注册HikariCP的metrics
HikariDataSource dataSource = new HikariDataSource();
// ... 配置dataSource
registry.config().meterFilter(MeterFilter.ignoreNamePrefix("hikari"));
return registry;
}
}
在Prometheus + Grafana中,可以监控:
hikaricp_connections_active:活跃连接数hikaricp_connections_idle:空闲连接数hikaricp_connections_pending:等待连接数(如果这个值持续不为0,说明连接池不够)
五、完整架构图
┌─────────────┐
│ Nginx/网关 │
└──────┬──────┘
│
┌────────────┼────────────┐
│ │ │
┌─────▼─────┐ ┌───▼────┐ ┌────▼─────┐
│ Order服务 │ │库存服务│ │ 用户服务 │
└─────┬─────┘ └───┬────┘ └────┬─────┘
│ │ │
└───────────┼───────────┘
│
┌─────────▼─────────┐
│ ShardingSphere │
│ (分库分表+读写分离)│
└─────────┬─────────┘
│
┌───────────────┼───────────────┐
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ Master │ │ Slave 1 │ │ Slave 2 │
│ (ds0) │ │ (ds1) │ │ (ds2) │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
└──────────────┴──────────────┘
│
┌───────▼───────┐
│ Redis缓存 │
│ (热点数据) │
└───────────────┘
数据流向:
- 写请求 → 主库(ds0)
- 读请求 → 从库(ds1/ds2/ds3),轮询负载均衡
- 热点数据 → 先查Redis,命中则返回,未命中则查DB并回写缓存
六、性能测试:分库分表前后对比
我们用JMeter模拟618场景,测试数据:
| 场景 | QPS | 平均响应时间 | 错误率 | 数据库CPU |
|---|---|---|---|---|
| 单库单表 | 500 | 800ms | 5% | 95% |
| 分库分表+读写分离 | 5000 | 80ms | 0.1% | 40% |
| 分库分表+读写分离+连接池优化 | 10000 | 50ms | 0% | 35% |
结论:
- 分库分表后,QPS提升10倍,响应时间降低90%。
- 连接池优化后,进一步降低响应时间,错误率归零。
七、常见问题与坑
7.1 跨分片查询怎么办?
比如查“所有用户的订单”,需要扫所有分片,性能很差。
解决方案:
- 避免跨分片查询, redesign表结构,把常用查询字段冗余到主表中。
- 用ES(Elasticsearch)做搜索引擎,定期同步MySQL数据。
7.2 分库分表后,分页查询不准?
每页10条,查第10
