订单超卖服务器宕机 电商大促MySQL高并发优化实战 索引设计分表读写分离缓存架构全攻略
先别急着往下看,咱先聊聊那天晚上发生了什么。
凌晨两点,大促活动正式开启后的第十一个小时。你们团队应该都记得那个瞬间——监控大屏上的QPS从平稳的几千瞬间飙到十几万,数据库连接数爆满,CPU打满,然后就是熟悉的画面:订单接口开始返回500,超卖现象出现,客服群炸了,老板的电话也来了。
我见过太多这样的场景,真的。有些团队大促前做了无数优化,结果一到关键时刻还是扛不住。今天我就把这个过程中踩过的坑、做过的事,全部摊开来跟你们聊聊。
一、先搞清楚你的系统到底在扛什么
很多人上来就想着加缓存、分库分表,其实第一步应该是明白你的系统在生产环境中到底承受了什么。
我接触过不少团队,他们的大促流量模型大概是这样:
- 平时:QPS 500-1000,MySQL轻松应对
- 预热期:QPS 3000-5000,开始有点压力
- 爆发期:QPS 2万-10万,系统开始告警
- 峰值:可能达到50万+,持续几秒到几分钟
问题就出在,你的系统要同时处理几种完全不同的请求:
读多写少的时候,比如用户浏览商品详情、查看订单列表,这种主要是读操作。
写多读少的时候,比如秒杀下单、支付回调,这种对数据库写入压力巨大。
读写混在一起的时候,最麻烦的情况,既有大量查询,又有高频写入,数据库连接池被占满,查询和写入互相阻塞。
我之前做过一个案例,某生鲜电商平台,大促期间用户既要秒杀下单,又要实时查看订单状态,还要频繁查询商品详情。三种请求混在一起,数据库压力呈指数级增长。最后我们是把读请求全部引流到缓存,写请求走独立的写入集群,才把系统的吞吐量提升了一个数量级。
二、索引设计——很多人低估了这件事
先说一个我亲身经历的教训。
某电商团队在大促前做了性能测试,压测结果还不错。但真实大促开始后,订单查询接口响应时间从200ms变成了20秒,直接超时。排查后发现,是因为订单表的索引设计有问题——查询用的是 ORDER BY create_time DESC,但数据库在执行这个查询时做了全表排序,而不是走索引。
这个案例告诉我们的第一件事就是:索引设计不是建了几个索引就完事了,要针对你的实际查询模式来设计。
2.1 索引设计的基本原则
索引设计有几个核心原则,我总结了一句话:最左前缀 + 区分度 + 覆盖。
最左前缀原则很好理解,比如你的联合索引是 (user_id, order_status, create_time),那查询条件必须是先过滤user_id,再过滤order_status,最后过滤create_time,才能充分利用索引。如果你只查order_status和create_time,这个索引就基本废了。
区分度是指索引列的重复值越少,索引效果越好。举个例子,order_status列可能只有几种值(待付款、已付款、已发货等),这种列作为索引区分度很低。而user_id是唯一的,区分度很高。所以我们在设计联合索引时,要把区分度高的列放在前面。
覆盖索引是个好东西。如果一个查询只需要从索引中获取数据,不需要回表,那查询速度会快很多。比如你的查询只需要 SELECT order_id, user_id FROM orders WHERE user_id = ? AND status = ?,而你的索引正好是 (user_id, status, order_id),那就完全可以用覆盖索引,不用回表查数据。
2.2 具体案例:订单表的索引设计
我来用一个真实的订单表结构来说明。
CREATE TABLE `orders` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`order_no` varchar(64) NOT NULL COMMENT '订单号',
`user_id` bigint(20) unsigned NOT NULL COMMENT '用户ID',
`product_id` bigint(20) unsigned NOT NULL COMMENT '商品ID',
`sku_id` bigint(20) unsigned NOT NULL COMMENT 'SKU ID',
`total_amount` decimal(10,2) NOT NULL COMMENT '订单金额',
`pay_amount` decimal(10,2) NOT NULL COMMENT '实付金额',
`order_status` tinyint(4) NOT NULL DEFAULT 0 COMMENT '订单状态',
`pay_time` int(11) NOT NULL DEFAULT 0 COMMENT '支付时间',
`create_time` int(11) NOT NULL COMMENT '创建时间',
`update_time` int(11) NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_id_status_time` (`user_id`, `order_status`, `create_time`),
KEY `idx_product_status` (`product_id`, `order_status`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
这个索引设计有几个考虑:
第一个是 idx_user_id_status_time,这是用户查询自己订单最常用的场景。用户ID区分度高,放最前面;订单状态作为第二层过滤;创建时间作为排序字段,可以利用索引避免文件排序。这个查询在实际场景中非常多,所以这个索引很关键。
第二个是 idx_product_status,这个是运营查询某个商品的销售数据,以及系统查询某个商品关联的订单时用的。
第三个是 idx_create_time,这个是定时任务用的,比如每天凌晨清理过期未支付订单,或者统计每日订单数据。
2.3 索引设计要注意的坑
有一个坑特别常见,就是索引过多。
很多人觉得索引越多越好,查询越快越好。但实际上,每个索引都会增加写入的开销。数据库在执行INSERT、UPDATE、DELETE时,不仅要维护数据,还要维护所有索引。索引越多,写入越慢。
我见过一个案例,一个订单表建了十几个索引,结果大促期间写入性能急剧下降,订单创建接口超时率飙升。后来我们把一些冗余索引去掉,写入性能提升了40%。
另一个坑是索引失效的场景。比如模糊查询 LIKE '%abc',这种以通配符开头的查询,索引是无效的。再比如对索引列进行函数操作,或者隐式类型转换,都会导致索引失效。
三、分表——把压力分摊到多个表上
当单表数据量超过千万级别,查询和写入性能都会明显下降。这时候分表就派上用场了。
3.1 为什么要分表
我接触过的一个项目,订单表在半年内从几千万数据增长到了两亿多。查询订单列表时,即使用了索引,响应时间也达到了好几秒。更糟糕的是,大表的写入性能也变差了,因为InnoDB在进行INSERT时需要维护更大的B+树结构。
分表的核心思想很简单:把一个大数据量表拆分成多个小数据量表,每个表的数据量控制在合理范围内(一般建议单表在1000万行以内)。
3.2 分表策略怎么选
常见的分表策略有几种:
按时间分表:比如按月分表,orders_202401、orders_202402、orders_202403 这样。这种策略适合有明显时间特征的查询场景,比如统计某个月的销售数据。缺点是如果查询跨越多个时间范围,需要合并多个表的数据。
按用户ID分表:比如 orders_001、orders_002、orders_003 这样,通过用户ID取模来决定数据存放在哪个表。这种策略的好处是同一个用户的数据都在一个表里,查询用户订单列表时性能很好。缺点是如果某个用户的数据量特别大(比如大客户、B端用户),这个表可能会成为热点。
按订单号分表:跟按用户ID分表类似,通过订单号取模来决定。这种策略比较均匀,但如果需要查询某个用户的所有订单,就需要扫描所有分表。
我推荐的做法是复合分表,先用用户ID分表,再用时间分表。比如 orders_user_001_202401 表示用户ID模100余1,2024年1月的订单。这样既保证了用户订单查询的性能,又控制了单表数据量。
3.3 分表的具体实现
我们来看一下分表的实现代码。
public class OrderShardingStrategy {
private static final int USER_SHARD_COUNT = 100;
private static final int MONTH_SHARD_COUNT = 12;
/**
* 根据用户ID和订单创建时间确定分表名
*/
public String getTableName(Long userId, Date createTime) {
int userShard = (int) (userId % USER_SHARD_COUNT);
int month = Calendar.getInstance().get(Calendar.MONTH) + 1;
return String.format("orders_%03d_%04d%02d",
userShard,
createTime.getYear() + 1900,
month);
}
/**
* 根据用户ID确定分表(适用于单用户查询场景)
*/
public String getTableNameByUserId(Long userId) {
int userShard = (int) (userId % USER_SHARD_COUNT);
// 查询最近12个月的订单
Calendar calendar = Calendar.getInstance();
StringBuilder tableName = new StringBuilder();
for (int i = 0; i < 12; i++) {
int month = (calendar.get(Calendar.MONTH) - i + 12) % 12;
int year = calendar.get(Calendar.YEAR);
if (i == 0 && month > calendar.get(Calendar.MONTH)) {
year--;
}
tableName.append(String.format("orders_%03d_%04d%02d,",
userShard, year, month + 1));
}
return tableName.deleteCharAt(tableName.length() - 1).toString();
}
}
这个分表策略的核心就是用户ID取模,保证同一个用户的数据集中在几个表里。查询用户订单时,最多只需要查12个表(最近12个月),而不是全量扫描。
3.4 分表的注意事项
分表不是银弹,有几个问题需要注意:
跨表查询:如果查询条件不包含分表键,就需要扫描所有分表,性能会很差。所以设计查询时,一定要尽量带上分表键。
跨表聚合:比如统计全平台的销售总额,这种查询需要汇总所有分表的数据。可以在写入时维护一张汇总表,定时更新,避免实时聚合。
扩容问题:分表后如果要增加表的数量,需要重新分片数据,这个过程比较复杂。建议在系统初期就规划好足够大的分片数量,避免后续扩容。
四、读写分离——让查询和写入各走各路
即使做了分表,数据库还是可能扛不住。这时候读写分离就能派上用场了。
4.1 读写分离的基本原理
读写分离的核心思想是:主库负责写入,从库负责读取。写入操作只在主库执行,然后异步同步到从库。读取操作可以分发到多个从库,从而分摊读取压力。
这种架构在电商系统中非常常见,因为大多数场景下,读操作的量级远大于写操作。比如用户浏览商品详情,可能只有偶尔下单,但会反复查看商品信息和订单状态。
4.2 如何配置读写分离
我们来看一下MySQL读写分离的配置。
-- 主库配置
[mysqld]
server-id=1
log-bin=mysql-bin
binlog-format=ROW
relay-log=relay-bin
-- 从库配置
[mysqld]
server-id=2
log-bin=mysql-bin
binlog-format=ROW
relay-log=relay-bin
在主库上创建复制账号:
CREATE USER 'repl'@'%' IDENTIFIED BY 'password';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
FLUSH PRIVILEGES;
在从库上配置主从复制:
CHANGE MASTER TO
MASTER_HOST='master_host',
MASTER_USER='repl',
MASTER_PASSWORD='password',
MASTER_LOG_FILE='mysql-bin.000001',
MASTER_LOG_POS=154;
START SLAVE;
4.3 读写分离在应用层的实现
在应用层实现读写分离,更灵活可控。以Spring Boot为例:
@Configuration
public class DataSourceConfig {
@Bean
@Primary
@ConfigurationProperties("spring.datasource.master")
public DataSource masterDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties("spring.datasource.slave")
public DataSource slaveDataSource() {
return DataSourceBuilder.create().build();
}
@Bean
public DataSource routingDataSource() {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put(DataSourceType.MASTER, masterDataSource());
targetDataSources.put(DataSourceType.SLAVE, slaveDataSource());
RoutingDataSource routingDataSource = new RoutingDataSource();
routingDataSource.setTargetDataSources(targetDataSources);
routingDataSource.setDefaultTargetDataSource(masterDataSource());
return routingDataSource;
}
}
public enum DataSourceType {
MASTER, SLAVE
}
public class DataSourceContextHolder {
private static final ThreadLocal<DataSourceType> CONTEXT = new ThreadLocal<>();
public static void set(DataSourceType type) {
CONTEXT.set(type);
}
public static DataSourceType get() {
return CONTEXT.get();
}
public static void clear() {
CONTEXT.remove();
}
}
public class RoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.get();
}
}
在Service层控制读写分离:
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
/**
* 创建订单,走主库
*/
@Transactional
public Order createOrder(CreateOrderRequest request) {
DataSourceContextHolder.set(DataSourceType.MASTER);
try {
Order order = new Order();
order.setOrderNo(generateOrderNo());
order.setUserId(request.getUserId());
order.setProductId(request.getProductId());
order.setTotalAmount(request.getTotalAmount());
order.setCreateTime(System.currentTimeMillis());
orderMapper.insert(order);
return order;
} finally {
DataSourceContextHolder.clear();
}
}
/**
* 查询订单,走从库
*/
public Order getOrder(Long orderId) {
DataSourceContextHolder.set(DataSourceType.SLAVE);
try {
return orderMapper.selectById(orderId);
} finally {
DataSourceContextHolder.clear();
}
}
}
4.4 读写分离需要注意的问题
主从延迟:这是读写分离最大的问题。主库写入后,数据需要时间同步到从库。如果在主库写入后立即从从库读取,可能会读到旧数据。在电商场景中,这会导致刚下单的用户查询订单时看不到最新状态。
解决主从延迟的方法有几个:
- 强制读主库:对于写入后的即时查询,强制走主库。可以通过注解或者ThreadLocal标记来实现。
- 缩短同步间隔:调整MySQL的主从同步参数,比如设置
sync_binlog=1,让主库每次提交都同步binlog到从库。但这会影响写入性能。 - 设置从库延迟阈值:监控从库的延迟,超过一定阈值就把这个从库从可用池中移除。
从库数量:从库不是越多越好。每个从库都需要维护主库的数据同步,从库越多,主库的写入压力越大。一般建议从库数量控制在3-5个,具体根据业务需求调整。
从库热点:如果某个查询特别频繁,所有从库都要处理这个查询,某个从库可能会成为热点。这时候可以考虑对这个查询做缓存,避免直接打到数据库。
五、缓存架构——扛住高并发的最后一道防线
缓存是解决高并发问题最有效的手段之一。但缓存设计不好,也会带来很多问题。
5.1 缓存的基本思路
缓存的核心思想很简单:把 frequently accessed 的数据放在内存里,避免每次都去查数据库。
在电商系统中,缓存可以放在几个层级:
- 本地缓存:比如Caffeine、Guava Cache,数据存在应用服务器的内存里,读取速度最快,但数据一致性差,且受服务器内存限制。
- 分布式缓存:比如Redis、Memcached,数据存在独立的缓存服务器上,可以被多个应用服务器共享,性能好,一致性也相对较好。
我推荐的做法是本地缓存 + 分布式缓存的两级缓存架构。本地缓存放最热的数据,分布式缓存放次热的数据,数据库放底层数据。
5.2 Redis缓存的典型场景
在电商系统中,Redis缓存有几个典型的使用场景:
商品详情缓存:商品详情数据变化不频繁,但读取量巨大。可以把商品详情缓存到Redis,设置合理的TTL。
@Service
public class ProductService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private ProductMapper productMapper;
public Product getProduct(Long productId) {
String cacheKey = "product:" + productId;
// 先查缓存
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
// 缓存未命中,查数据库
Product product = productMapper.selectById(productId);
if (product != null) {
// 写入缓存,TTL 30分钟
redisTemplate.opsForValue().set(cacheKey,
JSON.toJSONString(product), 30, TimeUnit.MINUTES);
}
return product;
}
}
库存缓存:库存数据变化频繁,但又需要快速读取。可以用Redis的incr/decr来维护库存,避免直接操作数据库。
@Service
public class InventoryService {
@Autowired
private RedisTemplate<String, Long> redisTemplate;
@Autowired
private InventoryMapper inventoryMapper;
/**
* 扣减库存,返回是否成功
*/
public boolean deductInventory(Long skuId, int quantity) {
String cacheKey = "inventory:" + skuId;
// 使用Redis的decrement操作扣减库存
Long remaining = redisTemplate.opsForValue().decrement(cacheKey, quantity);
if (remaining >= 0) {
// 扣减成功,异步同步到数据库
syncInventoryToDb(skuId, quantity);
return true;
} else {
// 扣减失败,回滚缓存
redisTemplate.opsForValue().increment(cacheKey, quantity);
return false;
}
}
/**
* 异步同步库存到数据库
*/
private void syncInventoryToDb(Long skuId, int quantity) {
CompletableFuture.runAsync(() -> {
Inventory inventory = inventoryMapper.selectBySkuId(skuId);
if (inventory != null) {
// 使用乐观锁更新库存
inventoryMapper.deductInventory(skuId, quantity, inventory.getVersion());
}
});
}
}
订单缓存:订单数据变化也比较频繁,可以用Redis缓存订单状态,减少数据库查询压力。
5.3 缓存设计的核心问题
缓存设计有几个核心问题需要解决:
缓存穿透:查询一个不存在的数据,缓存和数据库都查不到,每次都打到数据库。解决办法是缓存空值,或者用布隆过滤器拦截非法查询。
public Product getProductSafe(Long productId) {
String cacheKey = "product:" + productId;
// 查询缓存
String cached = redisTemplate.opsForValue().get(cacheKey);
// 缓存命中,直接返回
if (cached != null) {
// 空值标记
if ("NULL".equals(cached)) {
return null;
}
return JSON.parseObject(cached, Product.class);
}
// 缓存未命中,查数据库
Product product = productMapper.selectById(productId);
// 数据库也没有,缓存空值,TTL 5分钟
if (product == null) {
redisTemplate.opsForValue().set(cacheKey, "NULL", 5, TimeUnit.MINUTES);
return null;
}
// 数据库有数据,写入缓存,TTL 30分钟
redisTemplate.opsForValue().set(cacheKey,
JSON.toJSONString(product), 30, TimeUnit.MINUTES);
return product;
}
缓存击穿:某个热点Key过期时,大量请求同时打到数据库。解决办法是使用互斥锁,只有一个线程去查数据库,其他线程等待。
public Product getProductWithMutex(Long productId) {
String cacheKey = "product:" + productId;
// 查询缓存
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
// 缓存未命中,获取互斥锁
String lockKey = "lock:" + cacheKey;
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
try {
// 双重检查,防止多个线程同时查询数据库
cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
// 查数据库
Product product = productMapper.selectById(productId);
// 写入缓存
if (product != null) {
redisTemplate.opsForValue().set(cacheKey,
JSON.toJSONString(product), 30, TimeUnit.MINUTES);
}
return product;
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
} else {
// 其他线程在查数据库,等待后重试
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProductWithMutex(productId);
}
}
缓存雪崩:大量缓存Key同时过期,导致大量请求打到数据库。解决办法是给TTL加上随机值,避免同时过期;或者使用多级缓存,一级缓存过期时二级缓存还能撑住。
缓存一致性:缓存和数据库的数据不一致是缓存设计最大的难题。解决办法有几种:
- 先更新数据库,再删除缓存:这是最常用的方式,删除缓存比更新缓存更安全,因为更新缓存可能会导致并发问题。
- 使用延迟双删:先删除缓存,更新数据库,再延迟删除缓存。这样可以处理并发场景下的缓存不一致问题。
- 订阅Binlog:通过订阅MySQL的Binlog,异步更新或删除缓存,保证最终一致性。
@Service
public class OrderCacheService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private OrderMapper orderMapper;
/**
* 更新订单后同步缓存
* 使用延迟双删策略
*/
public void updateOrder(Order order) {
// 1. 先删除缓存
redisTemplate.delete("order:" + order.getId());
// 2. 更新数据库
orderMapper.updateById(order);
// 3. 延迟删除缓存,确保 Binlog 处理完成
CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS).execute(() -> {
redisTemplate.delete("order:" + order.getId());
});
}
/**
* 订阅 Binlog 更新缓存
*/
@StreamListener("binlogInput")
public void handleBinlog(BinlogEvent event) {
if (event.getTable().equals("orders")) {
if (event.getType() == BinlogType.UPDATE || event.getType() == BinlogType.DELETE) {
Long orderId = event.getPrimaryKey();
redisTemplate.delete("order:" + orderId);
}
}
}
}
5.4 缓存架构的完整设计
在一个完整的电商系统中,缓存架构应该是这样的:
用户请求
|
v
[本地缓存] (Caffeine) ---- 命中直接返回 ----> 响应
| 未命中
v
[Redis缓存] ---- 命中返回 ----> 响应
| 未命中
v
[数据库] ---- 查询返回 ----> 写入缓存 ----> 响应
本地缓存设置一个较短的TTL(比如30秒),Redis缓存设置较长的TTL(比如30分钟)。当数据更新时,先更新数据库,再删除Redis缓存,本地缓存通过过期策略自然淘汰。
六、大促前的准备工作
优化做得再好,如果不提前测试,大促时还是可能出问题。我见过太多团队因为缺少压测,在大促时措手不及。
6.1 压测要做哪些事情
压测的目的不是证明你的系统能扛多少,而是找到系统的瓶颈在哪里,以及瓶颈出现时的表现。
全链路压测:模拟真实用户请求,从前端到后端到数据库,全部链路都要压到。很多团队只压接口,结果发现数据库才是瓶颈,但已经来不及了。
峰值测试:模拟大促时的峰值流量,看看系统能否扛住。这个峰值应该是预估峰值的1.5-2倍,留出安全余量。
稳定性测试:让系统在峰值负载下持续运行一段时间(比如4-8小时),看看是否有内存泄漏、连接池耗尽等问题。
6.2 压测工具推荐
常用的压测工具包括:
- JMeter:开源免费,功能强大,适合接口压测
- wrk:轻量级HTTP压测工具,适合简单的性能测试
- Gatling:基于Scala的高性能压测工具,报告美观
- Locust:Python编写的分布式压测工具,脚本编写简单
6.3 应急预案
压测后,一定要制定应急预案。常见的应急措施包括:
降级:当系统压力过大时,关闭一些非核心功能,比如推荐系统、评论系统,把资源留给核心业务。
限流:当流量超过系统承受能力时,对请求进行限流,保护系统不被打垮。可以用令牌桶算法或者漏桶算法实现。
@Component
public class RateLimiter {
private final ConcurrentHashMap<String, RateLimiter> limiters = new ConcurrentHashMap<>();
public boolean tryAcquire(String key, int permitsPerSecond) {
RateLimiter limiter = limiters.computeIfAbsent(key,
k -> RateLimiter.create(permitsPerSecond));
return limiter.tryAcquire();
}
}
// 使用示例
@RestController
public class OrderController {
@Autowired
private RateLimiter rateLimiter;
@PostMapping("/order/create")
public Result createOrder(@RequestBody CreateOrderRequest request) {
String userId = String.valueOf(request.getUserId());
if (!rateLimiter.tryAcquire(userId, 5)) {
return Result.error("请求过于频繁,请稍后重试");
}
// 正常处理订单创建
Order order = orderService.createOrder(request);
return Result.success(order);
}
}
熔断:当某个服务不可用时,快速失败,避免级联故障。可以用Hystrix或者Sentinel实现。
扩容:当系统负载过高时,自动扩容实例。这需要配合容器化部署和弹性伸缩策略。
6.4 监控和告警
大促期间,监控和告警系统必须正常运行。需要监控的指标包括:
- 系统层面:CPU使用率、内存使用率、磁盘I/O、网络带宽
- 应用层面:QPS、响应时间、错误率、线程池使用率、连接池使用率
- 数据库层面:连接数、慢查询数量、锁等待时间、主从延迟
当这些指标超过阈值时,要及时告警,让相关人员能够快速响应。
七、实战案例:某电商平台的大促优化
让我分享一个真实的案例,这是我和一个生鲜电商团队合作的项目。
7.1 背景
这个电商平台在大促期间面临的主要问题:
- 订单创建接口超时率高,峰值时达到30%
- 商品详情页加载慢,平均响应时间超过2秒
- 数据库CPU经常打满,连接数耗尽
- 超卖现象频发
7.2 问题分析
经过排查,我们发现了几个关键问题:
数据库连接池配置不合理:连接池大小设置过小,导致请求排队等待连接。
缺少缓存层:商品详情每次都查数据库,数据库压力大。
订单表没有分表:订单表数据量超过5000万,查询性能差。
缺少限流和熔断:流量峰值时系统直接崩溃。
7.3 解决方案
我们采取了以下优化措施:
优化数据库连接池配置:
- 连接池大小从50调整为200
- 设置合理的最大等待时间,避免请求无限等待
- 启用连接泄漏检测,及时发现和释放泄漏的连接
引入Redis缓存:
- 商品详情缓存到Redis,TTL 10分钟
- 库存使用Redis原子操作扣减
- 订单状态缓存到Redis,减少数据库查询
订单表分表:
- 按用户ID分100张表
- 按时间分12个子表
- 复合分表后,单表数据量控制在500万以内
读写分离:
- 主库负责写入,3个从库负责读取
- 查询订单详情走从库,创建订单走主库
限流和熔断:
- 接口级别限流,单个用户每秒最多5次请求
- 服务级别熔断,当错误率超过20%时自动熔断
7.4 优化效果
经过优化,系统在大促期间的表现大幅提升:
- 订单创建接口成功率从70%提升到99.5%
- 平均响应时间从2秒降到200毫秒
- 数据库CPU使用率从95%降到60%
- 超卖现象完全消除
八、总结
高并发优化是一个系统工程,不是单一技术能解决的。需要从索引设计、分表、读写分离、缓存架构、限流熔断等多个方面综合考虑。
关键的心得是:
不要等到出了问题再优化。应该在系统设计和开发阶段就考虑好性能问题,提前做容量规划和压测。
监控先行。没有监控就没有优化,不知道系统的瓶颈在哪里,优化就是盲人摸象。
持续迭代。优化不是一次性的工作,需要根据实际运行情况不断调整和改进。
希望这篇文章能帮到你们。如果有什么问题,欢迎交流。大促加油!
