电商大促秒单MySQL崩了怎么办 索引优化读写分离缓存分库分表实战指南
去年双11,我凌晨三点被电话叫醒。线上告警群疯了似的刷屏:”QPS 飙到 12万,MySQL 连接池满了!”“主从延迟超过 30秒!”“订单创建接口 503!”
那年的惨状到现在我还记得清清楚楚。我们的MySQL主库从平时扛 2000 QPS,突然在零点那一刻冲到了 8万+,然后就是连锁崩溃——连接数打满、慢查询堆积、主从延迟爆炸、接口全面超时。技术团队全员待命,整整熬了4个小时才缓过来。
后来我们花了大半年时间,把整套架构彻底重构了一遍。今天这篇文章,就是把那些年踩过的坑、流过的泪,全部整理成一本实战手册。如果你也面临大促高并发的压力,或者正在做架构升级,这篇文章应该能帮你少熬几个通宵。
一、先别急着改架构,检查一下你的索引
很多团队一遇到性能问题,第一反应就是”上缓存”、”分库分表”。但在我们经历过的那次事故之后,回过头看,大部分MySQL崩盘,根因其实都是索引问题。
1.1 典型的场景
以我们的订单表为例,大促前这张表的数据量已经涨到了 2亿条。查询接口主要是:
- 按用户ID查订单:
SELECT * FROM orders WHERE user_id = ? ORDER BY create_time DESC - 按订单号查:
SELECT * FROM orders WHERE order_no = ? - 按状态+时间范围查:
SELECT * FROM orders WHERE status = ? AND create_time BETWEEN ? AND ?
看起来挺正常的表对吧?但当时我们的索引情况是这样的:
- 主键索引:
id(自增) - 唯一索引:
order_no - 只有一个普通索引:
idx_user_id(user_id)
你以为这就够了?我们当时也是这么想的。直到大促那天,”按状态+时间范围查”的查询直接扫了 8000万行数据。
1.2 索引失效的常见陷阱
让我给你列举几个大促场景下最容易踩的坑:
陷阱一:模糊查询左边放通配符
-- 这种写法索引直接失效
SELECT * FROM orders WHERE order_no LIKE '%ORD2023%'
正确的做法是尽量用等值查询,或者把可过滤的字段单独拎出来:
-- 先等值过滤,再在应用层做模糊匹配
SELECT * FROM orders WHERE order_no = 'ORD2023121100001'
陷阱二:复合索引的顺序搞反了
假设我们有一个复合索引 idx_status_time(status, create_time),查询是:
-- 没问题,符合最左前缀原则
SELECT * FROM orders WHERE status = 1 AND create_time > '2023-11-11 00:00:00'
-- 有问题!status 放在后面,索引只能用一部分
SELECT * FROM orders WHERE create_time > '2023-11-11 00:00:00' AND status = 1
等等,上面这个其实 MySQL 5.6+ 有优化器会做索引reorder,但为了保险起见,写SQL的时候还是按照索引顺序来最稳妥。
陷阱三:隐式类型转换
-- order_no 是 VARCHAR,但查询时传了数字
SELECT * FROM orders WHERE order_no = 123456789
MySQL 会做隐式类型转换,导致索引失效。这个bug特别隐蔽,平时用测试数据测不出问题,数据量一大就炸。
1.3 我们的优化方案
针对上面的订单表,我们重新设计了索引结构:
-- 主键
PRIMARY KEY (id),
-- 用户订单查询:按用户查最新订单
INDEX idx_user_time (user_id, create_time),
-- 状态+时间范围查询(用于运营后台统计)
INDEX idx_status_time (status, create_time),
-- 订单号唯一索引
UNIQUE KEY uk_order_no (order_no),
-- 支付状态+时间(用于超时未支付订单的定时任务)
INDEX idx_pay_status_time (pay_status, pay_time),
-- 商家维度查询
INDEX idx_seller_time (seller_id, create_time)
加了这几个复合索引之后,我们把慢查询日志拉出来,发现原来需要全表扫描的查询,现在全部走索引,响应时间从几百毫秒降到几毫秒。
1.4 如何系统地排查索引问题
不要凭感觉加索引,要用数据说话:
-- 查看表的索引使用情况
SHOW INDEX FROM orders;
-- 查看查询的执行计划(关键!)
EXPLAIN SELECT * FROM orders WHERE user_id = 123456 ORDER BY create_time DESC LIMIT 20;
-- 开启慢查询日志,定位真正的慢查询
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 超过1秒的查询记录为慢查询
EXPLAIN 的结果里,重点关注这几个字段:
| 字段 | 说明 | 理想值 |
|---|---|---|
| type | 连接类型 | const > eq_ref > ref > range > index > ALL |
| key | 实际使用的索引 | 不应该为 NULL |
| rows | 预估扫描行数 | 越少越好 |
| Extra | 额外信息 | 避免出现 Using filesort、Using temporary |
如果看到 type=ALL(全表扫描)或者 Using filesort,那基本可以确定索引有问题,需要重新设计。
二、读写分离:把读的压力分出去
索引优化解决了”查询慢”的问题,但大促的真正杀手是”查询太多”。我们的 MySQL 主库在那年双11,读请求占了 90% 以上,而写请求其实只有不到 10%。
这意味着什么?意味着我们的主库在白白承受巨大的读压力,而真正需要写能力的资源却被挤占了。
2.1 读写分离的基本原理
读写分离的思路很简单:主库只负责写,从库负责读。通过 MySQL 的原生复制机制,主库的数据会异步同步到从库。
┌──────────────┐
│ 应用层 │
└──────┬───────┘
│
┌────────────┼────────────┐
│ │ │
┌─────▼────┐ ┌───▼────┐ ┌───▼────┐
│ 主库(写) │ │ 从库1 │ │ 从库2 │
│ Master │ │ Slave │ │ Slave │
└──────────┘ └────────┘ └────────┘
2.2 我们的实现方案
我们用的是 ShardingSphere 来做读写分离,配合 Spring Boot:
@Configuration
public class DataSourceConfig {
@Bean
@Primary
public DataSource masterDataSource() {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl("jdbc:mysql://master-db:3306/ecommerce?useSSL=false&serverTimezone=Asia/Shanghai");
ds.setUsername("root");
ds.setPassword("your_password");
ds.setMaximumPoolSize(20); // 主库连接池小一点
ds.setMinimumIdle(5);
return ds;
}
@Bean
public DataSource slaveDataSource1() {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl("jdbc:mysql://slave-db1:3306/ecommerce?useSSL=false&serverTimezone=Asia/Shanghai");
ds.setUsername("root");
ds.setPassword("your_password");
ds.setMaximumPoolSize(50); // 从库连接池可以大一些
ds.setMinimumIdle(10);
return ds;
}
@Bean
public DataSource slaveDataSource2() {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl("jdbc:mysql://slave-db2:3306/ecommerce?useSSL=false&serverTimezone=Asia/Shanghai");
ds.setUsername("root");
ds.setPassword("your_password");
ds.setMaximumPoolSize(50);
ds.setMinimumIdle(10);
return ds;
}
@Bean
public AbstractDataSource routingDataSource(
@Qualifier("masterDataSource") DataSource master,
@Qualifier("slaveDataSource1") DataSource slave1,
@Qualifier("slaveDataSource2") DataSource slave2) {
Map<DataSource, Object> targetDataSources = new HashMap<>();
targetDataSources.put(master, "master");
targetDataSources.put(slave1, "slave1");
targetDataSources.put(slave2, "slave2");
MultipleDataSource routingDataSource = new MultipleDataSource();
routingDataSource.setDefaultTargetDataSource(master);
routingDataSource.setTargetDataSources(targetDataSources);
return routingDataSource;
}
}
public class MultipleDataSource extends AbstractRoutingDataSource {
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
public static void setReadMode() {
CONTEXT.set("slave");
}
public static void setWriteMode() {
CONTEXT.set("master");
}
public static void clear() {
CONTEXT.remove();
}
@Override
protected Object determineCurrentLookupKey() {
return CONTEXT.get();
}
}
然后在我们 Service 层加个切面,自动区分读写:
@Aspect
@Component
public class DataSourceAspect {
@Before("@annotation(readOnly)")
public void setDataSourceType(JoinPoint point, ReadOnly readOnly) {
MultipleDataSource.setReadMode();
}
@Before("@annotation(write)")
public void setWriteDataSourceType(JoinPoint point, Write write) {
MultipleDataSource.setWriteMode();
}
@AfterReturning
public void clearDataSourceType(JoinPoint point) {
MultipleDataSource.clear();
}
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ReadOnly {}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Write {}
这样,标注了 @ReadOnly 的方法就走从库,标注了 @Write 的就走主库,其他默认走主库。
2.3 读写分离的坑:数据延迟
这是读写分离最经典的问题——主从延迟。
用户刚下完单,立刻去查订单详情,结果发现查不到。这种体验在大促期间是致命的。
我们的应对方案是分层次处理:
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
/**
* 查询订单详情
* 策略:如果是刚创建的订单(5秒内),强制走主库
*/
@ReadOnly
public OrderDTO getOrderDetail(Long orderId) {
Order order = orderMapper.selectById(orderId);
// 如果开启了强一致性模式,或者订单创建时间很近,走主库
if (order == null && shouldForceMaster(orderId)) {
MultipleDataSource.setWriteMode();
order = orderMapper.selectById(orderId);
MultipleDataSource.clear();
}
return convertToDTO(order);
}
/**
* 判断是否需要强制走主库
* 逻辑:订单创建时间在5秒内的,强制读主库
*/
private boolean shouldForceMaster(Long orderId) {
Order order = orderMapper.selectById(orderId);
if (order == null) return false;
long createTime = order.getCreateTime().getTime();
long now = System.currentTimeMillis();
// 5秒内的订单,可能存在主从延迟
return (now - createTime) < 5000;
}
/**
* 创建订单(写操作)
*/
@Write
@Transactional
public OrderDTO createOrder(CreateOrderRequest request) {
Order order = new Order();
order.setOrderNo(generateOrderNo());
order.setUserId(request.getUserId());
order.setSellerId(request.getSellerId());
order.setStatus(OrderStatus.PENDING_PAYMENT);
order.setCreateTime(new Date());
orderMapper.insert(order);
// 插入订单明细
for (OrderItem item : request.getItems()) {
OrderItem orderItem = new OrderItem();
orderItem.setOrderId(order.getId());
orderItem.setProductId(item.getProductId());
orderItem.setQuantity(item.getQuantity());
orderItem.setPrice(item.getPrice());
orderItemMapper.insert(orderItem);
}
return convertToDTO(order);
}
}
对于特别敏感的业务(比如支付结果查询),我们还有一个兜底方案:主动刷新从库。当用户完成支付后,通过消息队列发送一个同步指令,强制主从数据同步,然后再让从库处理后续的读请求。
2.4 从库的负载均衡
多个从库的时候,还需要做负载均衡。我们用的是加权轮询策略:
public class SlaveLoadBalancer {
private final List<SlaveNode> slaves;
private final AtomicInteger position = new AtomicInteger(0);
public SlaveLoadBalancer(List<SlaveNode> slaves) {
this.slaves = slaves;
}
public SlaveNode next() {
if (slaves.isEmpty()) {
throw new RuntimeException("No slave available");
}
// 加权轮询
int totalWeight = slaves.stream()
.mapToInt(SlaveNode::getWeight)
.sum();
int random = new Random().nextInt(totalWeight);
int cumulative = 0;
for (SlaveNode slave : slaves) {
cumulative += slave.getWeight();
if (random < cumulative) {
return slave;
}
}
return slaves.get(slaves.size() - 1);
}
}
每台从库的权重根据配置动态调整,性能好的从库权重高,分担更多流量。
三、缓存:挡住80%的读请求
读写分离解决了读写分离的问题,但大促的真正挑战是QPS太高。哪怕你把读请求全部分发到从库,从库也扛不住啊。
这时候就需要缓存了。我们的经验是:好的缓存架构,可以让数据库的读请求减少 80%-90%。
3.1 多级缓存架构
我们采用的是两级缓存:本地缓存(Caffeine)+ 分布式缓存(Redis)。
用户请求
│
▼
┌──────────────┐
│ 本地缓存 │ ← 速度最快,微秒级,但数据不一致风险高
│ (Caffeine) │ 适合放不频繁变化、数据量小的热点数据
└──────┬───────┘
│ miss
▼
┌──────────────┐
│ 分布式缓存 │ ← 速度快,毫秒级,数据一致性较好
│ (Redis) │ 适合放中等热度、数据量中等的热点数据
└──────┬───────┘
│ miss
▼
┌──────────────┐
│ MySQL │ ← 最终数据源
└──────────────┘
3.2 本地缓存(Caffeine)的配置
@Configuration
public class CacheConfig {
@Bean
public Cache<String, OrderDTO> orderLocalCache() {
return Caffeine.newBuilder()
.maximumSize(5000) // 最多缓存5000条
.expireAfterWrite(30, TimeUnit.SECONDS) // 写后30秒过期
.expireAfterAccess(60, TimeUnit.SECONDS) // 60秒未访问则过期
.recordStats() // 开启统计
.build();
}
@Bean
public Cache<String, ProductDTO> productLocalCache() {
return Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(60, TimeUnit.SECONDS)
.expireAfterAccess(120, TimeUnit.SECONDS)
.recordStats()
.build();
}
@Bean
public Cache<String, UserDTO> userLocalCache() {
return Caffeine.newBuilder()
.maximumSize(20000)
.expireAfterWrite(120, TimeUnit.SECONDS)
.expireAfterAccess(300, TimeUnit.SECONDS)
.recordStats()
.build();
}
}
本地缓存的核心优势是速度极快,因为数据在进程内存里,不需要网络IO。但缺点是多实例之间数据不一致,所以需要配合分布式缓存使用。
3.3 分布式缓存(Redis)的实战
@Service
public class OrderCacheService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private Cache<String, OrderDTO> orderLocalCache;
/**
* 获取订单详情(多级缓存)
*/
public OrderDTO getOrderDetail(Long orderId) {
String cacheKey = "order:detail:" + orderId;
// 第一层:本地缓存
OrderDTO localCache = orderLocalCache.getIfPresent(cacheKey);
if (localCache != null) {
Metrics.counter("cache.hit.local.order").increment();
return localCache;
}
// 第二层:Redis缓存
String redisValue = redisTemplate.opsForValue().get(cacheKey);
if (redisValue != null) {
Metrics.counter("cache.hit.remote.order").increment();
OrderDTO dto = JSON.parseObject(redisValue, OrderDTO.class);
// 回填本地缓存
orderLocalCache.put(cacheKey, dto);
return dto;
}
// 缓存未命中,查数据库
Metrics.counter("cache.miss").increment();
OrderDTO order = queryOrderFromDB(orderId);
if (order != null) {
// 写入Redis,设置30秒过期
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(order), 30, TimeUnit.SECONDS);
// 写入本地缓存
orderLocalCache.put(cacheKey, order);
} else {
// 缓存穿透:数据不存在,也缓存一个空值,防止恶意查询
redisTemplate.opsForValue().set(cacheKey, "NULL", 60, TimeUnit.SECONDS);
}
return order;
}
/**
* 更新订单时,主动失效缓存
*/
public void invalidateOrderCache(Long orderId) {
String cacheKey = "order:detail:" + orderId;
// 删除Redis缓存
redisTemplate.delete(cacheKey);
// 删除本地缓存
orderLocalCache.invalidate(cacheKey);
}
/**
* 批量查询(用于列表页)
*/
public List<OrderDTO> batchGetOrders(List<Long> orderIds) {
return orderIds.parallelStream()
.map(this::getOrderDetail)
.filter(Objects::nonNull)
.collect(Collectors.toList());
}
}
3.4 缓存的经典问题及解决方案
问题一:缓存穿透
恶意用户或者 Bug 导致查询不存在的数据,每次都打到数据库。
解决方案:缓存空值。上面代码里已经体现了,查询不到数据时,缓存一个 “NULL” 标记,设置较短的过期时间(60秒)。
问题二:缓存击穿
热点数据突然过期,大量请求同时打到数据库。
解决方案:
- 互斥锁:只有一个线程去查数据库,其他线程等待
- 逻辑过期:不过期,而是设置一个逻辑过期时间,后台异步刷新
/**
* 互斥锁方案
*/
public OrderDTO getOrderWithMutex(Long orderId) {
String cacheKey = "order:detail:" + orderId;
// 先查缓存
OrderDTO dto = getFromCache(cacheKey);
if (dto != null) {
return dto;
}
// 缓存未命中,用分布式锁防止并发穿透
String lockKey = "lock:order:" + orderId;
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
try {
// 双重检查
dto = getFromCache(cacheKey);
if (dto != null) {
return dto;
}
// 查数据库
dto = queryOrderFromDB(orderId);
if (dto != null) {
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(dto), 30, TimeUnit.SECONDS);
orderLocalCache.put(cacheKey, dto);
}
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 没获取到锁,短暂等待后重试
Thread.sleep(50);
return getOrderWithMutex(orderId);
}
return dto;
}
问题三:缓存雪崩
大量缓存同时过期,请求瞬间打到数据库。
解决方案:
- 过期时间加随机抖动(比如 30秒 ± 5秒)
- 热点数据永不过期(逻辑过期方案)
// 过期时间加随机抖动
int baseTTL = 30;
int randomJitter = new Random().nextInt(10); // 0-10秒的随机抖动
redisTemplate.opsForValue().set(cacheKey, value, baseTTL + randomJitter, TimeUnit.SECONDS);
问题四:缓存一致性
数据更新了,缓存还没失效,用户看到的是旧数据。
解决方案:
- 先删缓存,再更新数据库(但有并发问题)
- 先更新数据库,再删缓存(相对可靠)
- 延迟双删(最稳妥但复杂)
/**
* 延迟双删策略
* 1. 先删缓存
* 2. 更新数据库
* 3. sleep一小段时间
* 4. 再删一次缓存(确保从库同步后的数据也被清除)
*/
public void updateOrder(OrderUpdateRequest request) {
String cacheKey = "order:detail:" + request.getOrderId();
// 1. 先删缓存
redisTemplate.delete(cacheKey);
orderLocalCache.invalidate(cacheKey);
// 2. 更新数据库
orderMapper.updateById(request);
// 3. 延迟再删一次缓存
// 使用延迟队列或者线程池延迟执行
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(500); // 等待主从同步
redisTemplate.delete(cacheKey);
orderLocalCache.invalidate(cacheKey);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
3.5 热点 Key 问题
大促期间,秒杀商品的详情可能会被几百万人同时查询,这就产生了热点 Key 问题。即使有缓存,Redis 单点也扛不住。
解决方案:
- 本地缓存+分布式缓存:每个服务实例都有自己的本地缓存,大量请求在本地就解决了
- Key 打散:把热点 Key 拆分成多个子 Key,分散到不同的 Redis 节点
- Redis Cluster:使用 Redis 集群,水平扩展
/**
* 热点 Key 打散方案
* 把一个热点 Key 拆分成 N 个子 Key,轮流使用
*/
@Service
public class HotKeyService {
private static final int SHARD_COUNT = 10;
public String getHotProductCache(Long productId) {
// 根据请求ID或时间打散
long shardIndex = (System.nanoTime() / 1000000) % SHARD_COUNT;
String shardKey = "product:hot:" + productId + ":shard:" + shardIndex;
String value = redisTemplate.opsForValue().get(shardKey);
if (value != null) {
return value;
}
// 缓存未命中,从数据库加载并写入所有分片
ProductDTO product = queryProductFromDB(productId);
if (product != null) {
String json = JSON.toJSONString(product);
for (int i = 0; i < SHARD_COUNT; i++) {
String key = "product:hot:" + productId + ":shard:" + i;
redisTemplate.opsForValue().set(key, json, 30, TimeUnit.SECONDS);
}
return json;
}
return null;
}
}
四、分库分表:当单库也扛不住的时候
缓存和读写分离解决了大部分问题,但到了大促的最高峰,我们的订单表数据量已经超过了 5亿,单表查询性能开始恶化。这时候就必须上分库分表了。
4.1 分库分表的思路
我们的方案是:按 user_id 分库,按 order_id 分表。
- 分4个库:
ecommerce_0,ecommerce_1,ecommerce_2,ecommerce_3 - 每个库分16张表:
orders_0~orders_15 - 总共 4 × 16 = 64 张表
user_id % 4 → 哪个库
order_id % 16 → 哪张表
4.2 ShardingSphere 配置
# application-sharding.yml
shardingjdbc:
dataSource:
names: ds0,ds1,ds2,ds3
ds0:
type: com.zaxxer.hikari.HikariDataSource
jdbc-url: jdbc:mysql://db0:3306/ecommerce_0?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
ds1:
type: com.zaxxer.hikari.HikariDataSource
jdbc-url: jdbc:mysql://db1:3306/ecommerce_1?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
ds2:
type: com.zaxxer.hikari.HikariDataSource
jdbc-url: jdbc:mysql://db2:3306/ecommerce_2?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
ds3:
type: com.zaxxer.hikari.HikariDataSource
jdbc-url: jdbc:mysql://db3:3306/ecommerce_3?useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
config:
sharding:
tables:
orders:
actual-data-nodes: ds$->{0..3}.orders_$->{0..15}
table-strategy:
standard:
sharding-column: order_id
sharding-algorithm-name: orders-table-inline
key-generate-strategy:
column: id
key-generator-name: snowflake
# 分库策略
binding-tables: orders,order_items
default-data-source-name: ds0
sharding-algorithms:
orders-db-inline:
type: INLINE
props:
algorithm-expression: ds$->{user_id % 4}
orders-table-inline:
type: INLINE
props:
algorithm-expression: orders_$->{order_id % 16}
key-generators:
snowflake:
type: SNOWFLAKE
props:
worker-id: 1
4.3 分库分表后的查询挑战
分库分表之后,很多查询会变得复杂。最典型的问题就是跨库查询。
场景一:按 user_id 查询
这个最简单,因为 user_id 就是分库键:
@ReadOnly
public List<OrderDTO> getOrdersByUserId(Long userId, int page, int size) {
// 直接根据 user_id 路由到对应的库
String sql = "SELECT * FROM orders WHERE user_id = ? ORDER BY create_time DESC LIMIT ? OFFSET ?";
// ShardingSphere 会自动路由到 ds_{userId % 4}
return orderMapper.selectList(sql, userId, size, (page - 1) * size);
}
场景二:按 order_no 查询
order_no 不是分片键,所以需要全局索引或者通过 order_id 反查:
@ReadOnly
public OrderDTO getOrderByOrderNo(String orderNo) {
// 方案一:通过 order_no 索引在分片键列上查询(需要order_no包含分片信息)
// 方案二:使用全局索引表
// 方案三:ES查询
// 这里用 ES 查询
SearchRequest request = new SearchRequest("orders_index");
SearchSourceBuilder builder = new SearchSourceBuilder();
builder.query(QueryBuilders.termQuery("order_no", orderNo));
request.source(builder);
SearchResponse response = elasticsearchClient.search(request, RequestOptions.DEFAULT);
if (response.getHits().getHits().length > 0) {
String orderIdStr = response.getHits().getHits()[0].getSourceAsMap().get("order_id").toString();
Long orderId = Long.parseLong(orderIdStr);
return getOrderById(orderId);
}
return null;
}
场景三:统计类查询(跨库聚合)
@ReadOnly
public OrderStatistics getDailyOrderStatistics(LocalDate date) {
// 需要跨所有库查询,使用 ShardingSphere 的跨库聚合
String sql = "SELECT seller_id, COUNT(*) as order_count, SUM(amount) as total_amount " +
"FROM orders " +
"WHERE create_time BETWEEN ? AND ? " +
"GROUP BY seller_id";
// ShardingSphere 会自动合并所有分片的结果
List<Map<String, Object>> results = orderMapper.selectMaps(sql,
date.atStartOfDay(), date.plusDays(1).atStartOfDay());
OrderStatistics stats = new OrderStatistics();
stats.setTotalOrders(results.stream().mapToLong(m -> ((Number)m.get("order_count")).longValue()).sum());
stats.setTotalAmount(results.stream().mapToDouble(m -> ((Number)m.get("total_amount")).doubleValue()).sum());
return stats;
}
4.4 分库分表的最佳实践
- 尽量用分片键查询:避免跨库查询,性能差距可能达到 10倍以上
- 分片键的选择:选择查询频率高、数据分布均匀的字段
- 预留扩容空间:一开始就考虑好未来可能的扩容,比如分库数用 2的幂次方
- 全局 ID 生成:使用雪花算法(Snowflake)生成全局唯一的 ID,避免分片后 ID 冲突
@Component
public class SnowflakeIdGenerator {
private final long workerId;
private final long datacenterId;
private final long sequence = 0L;
private long lastTimestamp = -1L;
public SnowflakeIdGenerator(long workerId, long datacenterId) {
if (workerId > 31 || workerId < 0) {
throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", 31));
}
if (datacenterId > 31 || datacenterId < 0) {
throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", 31));
}
this.workerId = workerId;
this.datacenterId = datacenterId;
}
public synchronized long nextId() {
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & 4095;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
// 时间戳(41位) + 数据中心(5位) + 工作节点(5位) + 序列号(12位)
return ((timestamp - EPOCH) << 22) | (datacenterId << 17) | (workerId << 12) | sequence;
}
private long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
private long timeGen() {
return System.currentTimeMillis();
}
}
五、大促前的完整实战 checklist
经历了多次大促之后,我们总结了一套完整的备战 checklist。每次大促前,都会按这个清单逐项检查:
5.1 数据库层面
- [ ] 索引审查:用
EXPLAIN检查所有核心查询的执行计划,确保没有全表扫描 - [ ] 慢查询治理:导出过去一个月的慢查询日志,逐条优化
- [ ] 连接池配置:根据预估流量调整连接池大小,预留 30% 的缓冲
- [ ] 主从延迟监控:配置主从延迟告警,延迟超过 5秒就报警
- [ ] 备份验证:确认备份可用,有恢复演练记录
5.2 缓存层面
- [ ] 缓存预热:大促前把热点数据加载到缓存,避免冷启动冲击
- [ ] 缓存监控:配置缓存命中率、内存使用量、过期 Key 数量的监控
- [ ] 降级预案:准备缓存不可用时的降级方案(直接查数据库)
- [ ] 热点检测:配置热点 Key 自动检测,超过阈值的 Key 自动打散
5.3 分库分表层面
- [ ] 扩容演练:提前进行分库分表的扩容演练
- [ ] 数据迁移方案:准备好数据迁移脚本和回滚方案
- [ ] 跨库查询梳理:梳理所有跨库查询,评估性能影响
- [ ] 全局ID验证:验证雪花ID生成器的稳定性和唯一性
5.4 监控告警
# 核心监控指标
monitoring:
mysql:
- connection_pool_active: 连接池活跃数告警阈值 80%
- slow_query_count: 慢查询数量告警阈值 100/分钟
- replicas_lag: 主从延迟告警阈值 10秒
- qps: QPS 告警阈值 基于历史峰值的 150%
redis:
- memory_usage: 内存使用率告警阈值 85%
- hit_rate: 缓存命中率告警阈值 90%
- connected_clients: 连接数告警阈值 90%
application:
- response_time_p99: P99响应时间告警阈值 500ms
- error_rate: 错误率告警阈值 1%
- thread_pool_active: 线程池活跃数告警阈值 80%
六、一个真实的故障复盘
去年双11之后,我们做了一个详细的故障复盘。以下是当时的时间线:
00:00:00 大促开始,流量瞬间飙升
00:00:15 QPS 从 2000 飙升到 50000
00:00:30 主库连接数达到上限(200/200)
00:00:45 开始有请求超时,503 错误出现
00:01:00 主从延迟达到 30秒
00:01:30 从库也开始扛不住,QPS 达到 80000
00:02:00 运维手动重启从库,暂时恢复
00:02:30 主库CPU 100%,慢查询堆积
00:03:00 开启熔断降级,非核心功能下线
00:04:00 流量高峰过去,系统逐渐恢复
00:08:00 全部恢复,历时4分钟
根因分析:
- 订单查询接口没有走缓存,每次直接查 MySQL
- 几个核心查询缺少复合索引,走了全表扫描
- 从库没有足够的水位,读写分离后从库依然被打满
- 没有热点 Key 的防护机制,秒杀商品详情直接打穿 Redis
改进措施:
| 问题 | 解决方案 | 效果 |
|---|---|---|
| 无缓存 | 本地缓存+Redis缓存,缓存命中率 95%+ | 数据库读请求减少 90% |
| 索引缺失 | 补充复合索引,慢查询减少 99% | 单查询响应从 500ms 降到 5ms |
| 从库不足 | 增加从库数量,读写分离+负载均衡 | 从库可扛 5万 QPS |
| 热点Key | 本地缓存+Key打散+预热 | 热点Key不再打穿缓存层 |
第二年双11,同样的流量规模,我们的 MySQL 主库 QPS 峰值只有 5000,远低于前一年的 80000。系统稳稳当当,零故障。
七、总结与思考
回看这几年的架构演进,有一个深刻的体会:没有银弹,只有层层防御。
索引优化是基础,成本低见效快,一定要优先做好。读写分离是标配,把读压力分散出去。缓存是核心武器,能挡住绝大部分流量。分库分表是最后的手段,用来应对数据量增长带来的极限压力。
但最重要的是:提前演练,提前准备。我们每年大促前都会做压力测试,模拟峰值流量,提前发现瓶颈。那些在生产环境炸开的坑,最好在测试环境先踩一遍。
最后送你一句话:架构设计不是猜出来的,是测出来的。不要相信自己的直觉,要相信监控数据。
祝你下次大促,安稳入睡。
