那天晚上,服务器监控大屏全线飘红,像极了我的心跳——虽然我是程序员,但那时候真的慌了。
起因是业务方突然提了个需求:“大促搞大了,预计峰值QPS要冲10万,老系统扛得住吗?”产品经理信誓旦旦说没问题,结果上线第一天,秒杀接口直接挂了。用户投诉、客服炸锅、老板开会追责,一顿操作猛如虎,最后发现根子在数据库。
MySQL在那一刻仿佛被掏空了所有力气,连接池爆满,CPU飙到100%,慢查询日志里全是SELECT * FROM orders WHERE user_id = ?这种看似无害却致命全表扫描的语句。更惨的是,订单数据虽然写进去了,但因为后续处理跟不上,积压严重,导致整个系统陷入恶性循环。
今天,我就以这个真实案例为背景,手把手带你拆解:面对高并发场景,如何设计一个既稳又快还能扛住的MySQL分库分表+缓存架构。我会从问题出发,逐步深入到技术选型、架构设计、代码实现以及性能调优,力求让你看完就能上手落地。
一、先认清敌人:为什么MySQL在高并发下会“崩”?
很多人误以为MySQL很弱,其实它很能打,但前提是你得用对地方、用对姿势。在高并发场景下,MySQL常见的“死法”主要有以下几种:
1. 连接风暴(Connection Storm)
每个请求都要建立数据库连接,而MySQL的连接是有开销的。当并发请求瞬间达到数万级别,连接池被打满,新请求只能排队等待,最终超时崩溃。
2. 锁竞争加剧(Lock Contention)
尤其是在写热点数据时,比如秒杀扣库存,所有请求都在争同一行数据,行锁升级成表锁,甚至引发死锁,事务阻塞时间急剧增长。
3. 缓存击穿与穿透(Cache Breakdown & Penetration)
热点key过期瞬间,大量请求直达数据库,造成突发流量冲击;或者大量非法查询请求因为缓存中无数据,反复穿透到DB。
4. 单点瓶颈(Single Point Bottleneck)
即便做了读写分离,主库的写压力依然集中,一旦主库扛不住,整个系统瘫痪。
5. I/O 瓶颈
磁盘读写能力有限,尤其是机械硬盘,高并发下I/O等待成为最大瓶颈。
二、解决方案概览:分层防御体系
面对上述问题,我们不能头痛医头脚痛医脚,而是要构建一个多层防御体系:
用户请求 -> CDN/网关 -> 应用服务 -> 缓存层(Redis) -> 消息队列 -> 分库分表(ShardingSphere) -> MySQL集群
|-> 异步处理 -> 订单后置处理服务 -> 最终一致性保障
这套架构的核心思想是:尽可能把压力挡在缓存和消息队列层面,让数据库只做它最擅长的事——持久化存储和复杂查询。
三、核心组件详解
3.1 缓存层:Redis 的多重角色
3.1.1 本地缓存 + 分布式缓存组合
首先,在应用服务器内部维护一个本地缓存(如Caffeine或Guava Cache),用于存储极少变化的静态数据或热点元数据。然后,通过Redis作为分布式缓存,应对高并发读取。
// 示例:本地缓存 + Redis 双重缓存
public class OrderCacheService {
private final Cache<String, Order> localCache = CacheBuilder.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build();
private final RedisTemplate<String, Order> redisTemplate;
public Order getOrder(String orderId) {
// 1. 先查本地缓存
Order order = localCache.getIfPresent(orderId);
if (order != null) {
return order;
}
// 2. 再查Redis
order = redisTemplate.opsForValue().get("order:" + orderId);
if (order != null) {
localCache.put(orderId, order); // 回写本地缓存
return order;
}
// 3. 最后查DB(注意加锁防止缓存击穿)
synchronized (this) {
order = orderMapper.selectById(orderId);
if (order != null) {
redisTemplate.opsForValue().set("order:" + orderId, order, 30, TimeUnit.MINUTES);
localCache.put(orderId, order);
}
}
return order;
}
}
3.1.2 热点数据预热
在秒杀活动开始前,通过后台任务将热门商品信息、库存数量等预加载到Redis中,避免活动开始时缓存冷启动带来的数据库压力。
@Scheduled(cron = "0 0 9 * * ?") // 每天早上9点预热
public void preloadHotItems() {
List<Long> hotItemIds = itemService.getHotItemsLast7Days();
for (Long itemId : hotItemIds) {
Item item = itemMapper.selectById(itemId);
String cacheKey = "item:" + itemId;
redisTemplate.opsForValue().set(cacheKey, item, 24, TimeUnit.HOURS);
// 同时将库存放入Redis,用于扣减前的预校验
String stockKey = "item:stock:" + itemId;
redisTemplate.opsForValue().set(stockKey, item.getStock(), 2, TimeUnit.HOURS);
}
}
3.1.3 防止缓存击穿:布隆过滤器
对于可能存在的恶意查询或随机ID查询,可以使用布隆过滤器提前拦截,避免无效请求打到Redis或DB。
public class BloomFilterInterceptor implements HandlerInterceptor {
private final BloomFilter<String> bloomFilter;
public BloomFilterInterceptor() {
// 初始化布隆过滤器,预计插入1000万条数据,误判率0.01%
this.bloomFilter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
10_000_000, 0.01
);
// 从DB加载已有数据到布隆过滤器
loadExistingData();
}
private void loadExistingData() {
List<String> existingKeys = orderMapper.selectAllOrderIds();
for (String key : existingKeys) {
bloomFilter.put(key);
}
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String orderId = request.getParameter("orderId");
if (orderId != null && !bloomFilter.mightContain(orderId)) {
// 布隆过滤器说不存在,直接返回404,不再查询
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return false;
}
return true;
}
}
3.2 分库分表:ShardingSphere 的实践
当单表数据量超过千万级别,或者单库QPS成为瓶颈时,分库分表就势在必行了。我们采用 Apache ShardingSphere 作为分片中间件,它提供了透明化的分片能力,对应用代码几乎无感。
3.2.1 分片策略选择
对于订单系统,常见的分片键有:
- 用户ID:适合按用户查询订单的场景,但可能导致数据倾斜(大V用户订单过多)
- 订单ID:均匀分布,但查询某用户订单需要广播查询
- 组合分片:主分片键+从分片键,平衡查询和写入
我们选择订单ID取模作为主分片策略,因为订单写入压力最大,需要均匀分布。
# ShardingSphere 配置示例
spring:
shardingsphere:
datasource:
names: ds0,ds1,ds2,ds3
ds0:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/order_db_0?useSSL=false
username: root
password: 123456
ds1:
# 同上,指向另外三个数据库实例
rules:
sharding:
tables:
orders:
actual-data-nodes: ds$->{0..3}.orders_$->{0..7} # 4库*8表=32张表
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: orders_$->{order_id % 8}
key-generators:
snowflake:
type: SNOWFLAKE
props:
worker-id: 123
3.2.2 跨分片查询优化
分库分表后,跨分片的查询会变得复杂。我们采用以下策略:
- 避免跨分片查询:尽量通过分片键查询,如按
order_id查订单 - 异步补偿查询:对于必须跨分片的查询(如按用户ID查所有订单),建立异步同步表,定期将用户维度的订单数据同步到汇总表
- 搜索引擎辅助:将订单数据同步到Elasticsearch,通过ES处理复杂查询
-- 创建用户订单汇总表(异步同步)
CREATE TABLE user_order_summary (
user_id BIGINT NOT NULL,
order_id BIGINT NOT NULL,
PRIMARY KEY (user_id, order_id),
INDEX idx_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 定时任务同步数据
@Scheduled(fixedDelay = 60000) // 每分钟同步一次
public void syncUserOrderSummary() {
// 从各个分片的orders表中查询最新变更的数据
// 然后写入user_order_summary表
// 具体实现略,建议使用Canal监听Binlog自动同步
}
3.2.3 分片键设计原则
- 写入均匀:确保分片后数据分布均匀,避免热点
- 查询友好:考虑主要查询场景,选择合适的分片键
- 扩展性:预留足够的分片空间,方便后期扩容
3.3 消息队列:削峰填谷的关键
MySQL崩盘的根本原因之一是写入压力瞬间过大。引入消息队列(Kafka/RocketMQ)可以将瞬时高峰流量削平,让数据库以稳定的节奏处理订单。
3.3.1 异步下单流程
传统同步流程:
用户点击下单 -> 应用服务校验 -> 写MySQL -> 返回结果
异步优化流程:
用户点击下单 -> 应用服务校验 -> 发送MQ消息 -> 立即返回“处理中”
-> 消费者异步写MySQL -> 通过WebSocket/轮询通知结果
@Service
public class OrderService {
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Autowired
private OrderMapper orderMapper;
public String createOrder(OrderRequest request) {
// 1. 基础校验
if (!validate(request)) {
throw new BusinessException("参数校验失败");
}
// 2. 生成订单ID(雪花算法)
String orderId = IdGenerator.generateOrderId();
// 3. 构建订单对象
Order order = new Order();
order.setOrderId(orderId);
order.setUserId(request.getUserId());
order.setItemId(request.getItemId());
order.setAmount(request.getAmount());
order.setStatus(OrderStatus.PENDING);
order.setCreateTime(System.currentTimeMillis());
// 4. 发送MQ消息,异步处理
rocketMQTemplate.syncSend("order-create-topic", order, 3000);
// 5. 立即返回,不等待DB写入
return orderId;
}
}
// 消费者:异步写入MySQL
@Component
@RocketMQMessageListener(
topic = "order-create-topic",
consumerGroup = "order-consumer-group"
)
public class OrderConsumer implements RocketMQListener<Order> {
@Autowired
private OrderMapper orderMapper;
@Override
public void onMessage(Order order) {
try {
// 写入MySQL
orderMapper.insert(order);
// 更新库存
inventoryService.deductInventory(order.getItemId(), order.getQuantity());
// 记录日志
log.info("订单创建成功: {}", order.getOrderId());
} catch (Exception e) {
log.error("订单创建失败: {}", order.getOrderId(), e);
// 失败重试或报警
sendMessageToDeadLetterQueue(order);
}
}
}
3.3.2 MQ 消息可靠性保障
- 生产者 Confirm 机制:确保消息成功发送到MQ
- MQ 持久化:消息在MQ中持久化,防止MQ重启丢失
- 消费者幂等性:通过唯一键(如order_id)保证重复消费不影响数据一致性
- 死信队列:处理失败的消息,人工介入或重试
// 幂等性检查示例
public void processOrder(Order order) {
// 1. 检查是否已处理
String lockKey = "order:processed:" + order.getOrderId();
Boolean isLocked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 24, TimeUnit.HOURS);
if (!isLocked) {
log.warn("订单已处理,跳过: {}", order.getOrderId());
return;
}
try {
// 2. 业务处理
orderMapper.insert(order);
inventoryService.deductInventory(order.getItemId(), order.getQuantity());
} catch (Exception e) {
// 3. 异常处理
redisTemplate.delete(lockKey); // 释放锁,允许重试
throw e;
}
}
3.4 库存扣减:秒杀场景的特殊处理
秒杀场景下,库存扣减是高并发热点操作,传统方式会导致严重的锁竞争。我们采用Redis预扣减 + MySQL最终一致性的方案。
3.4.1 Redis 预扣减流程
@Service
public class InventoryService {
@Autowired
private RedisTemplate<String, Integer> redisTemplate;
@Autowired
private OrderMapper orderMapper;
// 扣减库存,返回是否成功
public boolean deductStock(Long itemId, int quantity) {
String stockKey = "item:stock:" + itemId;
// Lua脚本保证原子性
String script =
"local stock = redis.call('get', KEYS[1]) " +
"if stock == false then return 0 end " +
"if tonumber(stock) < tonumber(ARGV[1]) then return 0 end " +
"redis.call('decrby', KEYS[1], ARGV[1]) " +
"return 1";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(stockKey),
String.valueOf(quantity)
);
return result != null && result == 1;
}
// 异步同步库存到MySQL
public void syncInventoryToDB(Long itemId, int deductedAmount) {
String sql = "UPDATE item SET stock = stock - ? WHERE item_id = ? AND stock >= ?";
orderMapper.updateInventory(sql, deductedAmount, itemId, deductedAmount);
}
}
3.4.2 超卖防护
即使Redis预扣减,也需要在MySQL层面做最终校验,防止并发情况下超卖。
-- MySQL 乐观锁更新
UPDATE item
SET stock = stock - #{quantity}, version = version + 1
WHERE item_id = #{itemId} AND stock >= #{quantity} AND version = #{version};
如果更新影响行数为0,说明库存不足,需要回滚Redis中的扣减。
public boolean processOrder(Long itemId, int quantity) {
// 1. Redis预扣减
boolean deducted = inventoryService.deductStock(itemId, quantity);
if (!deducted) {
return false; // 库存不足
}
try {
// 2. 异步写入订单
orderService.createOrder(itemId, quantity);
// 3. 同步库存到MySQL
boolean synced = inventoryService.syncInventoryToDB(itemId, quantity);
if (!synced) {
// 超卖发生,回滚Redis库存
inventoryService.rollbackStock(itemId, quantity);
throw new BusinessException("库存超卖,订单已取消");
}
return true;
} catch (Exception e) {
// 4. 异常回滚Redis库存
inventoryService.rollbackStock(itemId, quantity);
throw e;
}
}
四、性能优化与监控
4.1 MySQL 参数调优
4.1.1 连接池配置
# my.cnf 配置
max_connections = 2000 # 最大连接数
innodb_buffer_pool_size = 8G # InnoDB缓冲池大小,建议设为物理内存的50%-70%
innodb_log_file_size = 1G # redo log大小,越大写入性能越好
innodb_flush_log_at_trx_commit = 2 # 每秒刷盘,平衡性能与安全性
sync_binlog = 0 # 不强制刷盘,提升写入性能
4.1.2 慢查询优化
定期分析慢查询日志,优化SQL语句和索引。
”`bash
使用 mysqldumpslow 分析慢查询
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log