那天凌晨两点,老板把我从被窝里叫醒,电话里声音都在抖:“完了,活动刚开始,系统崩了,订单进不去,用户都在骂街。”
我顶着睡眼惺忪爬起来,打开监控大屏,好家伙,CPU 直接飙到 100%,内存还在疯狂跳动。最要命的是数据库连接数,那个数值像坐火箭一样往上冲,最后达到了 1000 的上限,然后——断了。全断了。
那一刻我才真正明白,教科书上的“高并发”三个字,写在纸上轻飘飘的,压在服务器上就是几百万的GMV和无数投诉。今天,我就把这半年里踩过的坑、调过的优、重构过的架构,掰开了揉碎了讲给你听。这不是为了炫技,是为了让下一场秒杀,不再是一场噩梦。
第一波冲击:为什么连接池会爆?
我们先别急着谈读写分离,先谈谈那个让我们一夜白头的问题——连接池爆满。
很多人以为,只要给数据库配置个大的连接数(比如把 max_connections 设成 5000),问题就解决了。大错特错。
想象一下,你的数据库就像一家银行,连接池里的连接就是柜员。银行大厅(应用服务器)里挤满了取钱的客户(用户请求)。如果柜员数量不够,客户就得排队;如果柜员太多,银行还得付高额工资,甚至忙不过来互相打架。
在我们的秒杀场景里,问题出在这里:
- 短连接频繁创建销毁:早期的代码里,每次HTTP请求过来,都是临时
DriverManager.getConnection(),处理完再close()。这就像每次取钱都临时招一个柜员,办完立刻辞退,第二天再来个新客户,再招一个……数据库服务端忙于处理TCP握手、身份验证、权限检查,根本没空干活。 - 连接泄漏:有些代码写得不严谨,
try-catch里忘了在finally块中关闭连接,或者异常时直接抛出,连接就“泄漏”了,永远占着茅坑不拉屎。 - 长事务:为了代码写得顺手,把很多操作放在一个事务里,一个请求占住连接好几秒,导致可用连接迅速枯竭。
我当时的监控显示,Active 连接数一直维持在 950+,而 Max 是 1000。意味着只要有 50 个连接因为网络抖动暂时不可用,整个系统就雪崩了。
解决方案:必须引入连接池,并且要配得科学。
我们换成了 HikariCP,号称“世界上最快的连接池”。配置参数不是随便填的,得根据公式来:
// application.yml 配置示例
spring:
datasource:
hikari:
maximum-pool-size: 50 # 核心参数,不是越大越好
minimum-idle: 10
idle-timeout: 30000
max-lifetime: 1800000
connection-timeout: 30000
leak-detection-threshold: 60000 # 开启泄漏检测,超过60秒没归还就报警
# 计算逻辑:
# 理想连接数 ≈ (核心数 * 2) + 有效磁盘数
# 对于高IO密集型数据库,通常建议设置为 CPU核数 * 2 左右,不要超过100-200,除非你有极强的DBA支撑。
千万别听信网上那些“开到1000才够”的鬼话。连接数越多,上下文切换开销越大,数据库CPU会被你自己拖垮。我们的经验是:连接池大小要限制,更重要的是限制单个请求的响应时间,超时立即释放连接。
第二波冲击:CPU被打爆,只读查询太多
连接池稳定了,活动第二天,问题又来了。CPU 使用率飙到 80%,但大部分时间,数据库都在干一些“无用功”——查热点商品详情、查分类列表、查用户基本信息。这些全是读操作,而且数据几乎不变,为什么要每次都去查磁盘?
这时候,读写分离出场了。
读写分离的本质
读写分离不是魔法,它是把压力分摊。主库(Master)负责写,从库(Slave)负责读。
用户请求 -> 网关 -> 路由层(判断是读还是写)
|
+---> 写操作 --> 主库 (MySQL-Master)
|
+---> 读操作 --> 从库1 (MySQL-Slave-1)
| 从库2 (MySQL-Slave-2)
| 从库3 (MySQL-Slave-3) [负载均衡]
我们在 Spring Boot 里用了 DynamicDataSource + AbstractRoutingDataSource 来实现动态路由。代码大概是这样:
public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.getDataSourceType();
}
}
public class DataSourceContextHolder {
private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();
public static void setMaster() {
CONTEXT.set("master");
}
public static void setSlave() {
CONTEXT.set("slave");
}
public static String getDataSourceType() {
return CONTEXT.get();
}
}
AOP 切面自动拦截:
@Aspect
@Component
public class DataSourceAspect {
@Pointcut("@annotation(com.example.annotation.Master)")
public void masterPointCut() {}
@Pointcut("@annotation(com.example.annotation.Slave)")
public void slavePointCut() {}
@Before("masterPointCut()")
public void setMasterDataSource() {
DataSourceContextHolder.setMaster();
}
@Before("slavePointCut()")
public void setSlaveDataSource() {
DataSourceContextHolder.setSlave();
}
}
使用的时候,程序员只需要加个注解:
@Master // 写操作,强制走主库
public void createOrder(Order order) { ... }
@Slave // 读操作,走从库
public Order getOrderById(Long id) { ... }
读写分离的坑:数据延迟
这是新手最容易翻车的地方。MySQL 主从同步是异步的(默认配置)。主库写完,数据传到从库有延迟,通常几毫秒到几百毫秒不等。
如果用户在秒杀下单后,立刻刷新页面“我的订单”,他可能会看到“未找到订单”。虽然从技术上讲,订单已经存在主库了,但他读的是还没同步过来的从库。
我们的解法:
- 强制读主库策略:在写操作之后的短时间内(比如 5 秒内),如果需要立即读取刚才写入的数据,强制路由到主库。
- 本地缓存兜底:在应用层做一个小的内存缓存,写入后立即更新缓存,读取时优先读缓存。
- 业务补偿:前端提示“数据同步中,请稍后查看”,引导用户稍后再刷新,或者查“我的订单”列表页(这个通常容忍度稍高,或者通过特殊标记主从分离)。
第三波冲击:热点数据被读爆,MySQL依然扛不住
读写分离解决了普通查询的压力,但秒杀场景有个致命特点:热点集中。
几百万人同时抢同一款 iPhone。这时候,所有读请求都指向同一张表、甚至同一行数据(商品 ID = 1001)。
虽然有三四个从库,但如果请求量足够大,比如 QPS 达到 50000,光是 SELECT * FROM product WHERE id=1001 这一条 SQL,每个从库都要处理 50000 次。从库的 CPU 照样会爆,磁盘 I/O 照样扛不住。
这就是为什么,读写分离只是第一步,缓存才是神器。
多级缓存架构
我们设计了 L1 + L2 两级缓存。
L1:Caffeine 本地缓存(应用服务器内存)
- 优点:速度极快,无网络开销。
- 缺点:数据不一致,内存占用,分布式环境下各节点数据不同。
- 适用:超热点数据,TTL 极短(比如 10-50ms)。
L2:Redis 分布式缓存
- 优点:共享数据,高性能,持久化。
- 缺点:有网络开销,集群架构复杂。
- 适用:大部分热点数据,TTL 稍长(比如 1-5 分钟)。
代码实现(Spring Cache + Redis):
@Service
public class ProductService {
@Autowired
private ProductMapper productMapper;
// L1 本地缓存:Caffeine,50ms 过期,最多存 1000 条
@Cacheable(value = "productLocal", key = "#id", cacheManager = "caffeineCacheManager")
public Product getProductLocal(Long id) {
return productMapper.selectById(id);
}
// L2 分布式缓存:Redis,1分钟过期
@Cacheable(value = "productRedis", key = "#id", cacheManager = "redisCacheManager")
public Product getProductRedis(Long id) {
// 注意:这里其实是双重检查锁的变种,防止缓存击穿
// 实际生产中会用 Spring Cache 的拦截器或自定义注解处理
return getProductLocal(id);
}
@CacheEvict(value = "productLocal", key = "#id")
@CacheEvict(value = "productRedis", key = "#id")
public void updateProduct(Product product) {
productMapper.updateById(product);
// 更新后清除缓存,保证一致性
}
}
缓存三大杀手
用了缓存,问题就少了吗?不,新问题更隐蔽。
缓存穿透:查询一个根本不存在的数据(比如 ID = -1),缓存不落,每次请求都打到 DB。
- 解法:布隆过滤器(Bloom Filter)拦截,或者对空结果也缓存一个极短 TTL 的空对象。
缓存击穿:某个热点 key 过期瞬间,大量请求同时涌向 DB。
- 解法:逻辑过期(不设置 TTL,而是设置逻辑过期时间,后台异步刷新);或者使用分布式锁,只让一个请求查 DB,其他等待。
缓存雪崩:大量 key 同时过期,或者 Redis 宕机。
- 解法:TTL 加随机值(比如 1-5 分钟随机),避免同时过期;Redis 集群高可用。
我们在这次重构中,重点解决了击穿问题,因为秒杀场景下,单点热点数据过期带来的冲击是最致命的。我们采用了“逻辑过期”方案:
public Product getProductWithLogicalExpire(Long id) {
String key = "product:" + id;
String json = redisTemplate.opsForValue().get(key);
if (json == null) {
// 缓存未命中,查库并写入
return reloadFromDB(id);
}
ProductCacheDto cacheDto = JSON.parseObject(json, ProductCacheDto.class);
Product product = cacheDto.getProduct();
// 检查逻辑过期时间
if (System.currentTimeMillis() > cacheDto.getExpireTime()) {
// 异步刷新,不阻塞当前请求
CompletableFuture.runAsync(() -> reloadFromDB(id));
// 返回旧数据,保证响应速度
}
return product;
}
这样,即使 Redis 挂了或者数据过期,用户也能在短时间内拿到旧数据,而不会瞬间压垮数据库。
第四波冲击:写操作也扛不住了,库表结构成为瓶颈
读的问题解决了,但写呢?
秒杀下单,是典型的“写密集”操作。几千人在同一秒点击“提交订单”。数据库的 InnoDB 引擎在处理高并发写时,行锁竞争非常激烈。
即便我们把订单表分成了 16 个库(分库分表),如果所有请求都集中在某几个分片上,或者单表数据量过大导致索引失效,性能依然会瓶颈。
这时候,分库分表是终极手段。
为什么要分库分表?
- 单表数据量过大:MySQL 官方建议单表不超过 500-1000 万行。超过后,索引效率下降,全表扫描代价极高,甚至影响备份恢复时间。
- 单机 I/O 瓶颈:一台数据库服务器,磁盘 I/O 和网络带宽是有上限的。分片后,数据分散到多台机器,I/O 压力分摊。
- 垂直分库:把不同业务模块拆分到不同数据库(订单库、用户库、商品库),避免不同业务互相抢资源。
分片策略:怎么分?
这是最关键的技术决策。常见的分片键有:
按用户 ID 取模:
shard_id = user_id % 16- 优点:同一用户的订单集中在一个库,查询用户订单历史很快。
- 缺点:如果某个大 V 用户有海量订单,会导致该分片数据倾斜(Hotspot)。
按订单 ID 取模:
shard_id = order_id % 16- 优点:写操作均匀分布,避免热点。
- 缺点:查询用户订单历史需要跨库查询(广播查询),性能差。
我们的选择:混合策略。
- 订单表:按
order_id分片,因为下单是无序的,ID 全局唯一且分布均匀,能保证写压力均匀分散。 - 订单详情表:与订单表同库,通过
order_id关联。 - 用户订单视图:为了查询用户历史订单,我们在 ES(Elasticsearch)中建立了索引,而不是在 MySQL 里做跨库查询。
分库分表中间件:ShardingSphere
我们不自己造轮子,用 Apache ShardingSphere。它提供了透明的数据库分片能力,对业务代码几乎无侵入。
# sharding-jdbc 配置示例
spring:
shardingsphere:
datasource:
names: ds0,ds1,ds2,ds3 # 4个数据源
ds0:
# ... 配置连接池
ds1:
# ...
ds2:
# ...
ds3:
# ...
rules:
sharding:
tables:
t_order:
actual-data-nodes: ds$->{0..3}.t_order_$->{0..3} # 4库4表,共16张表
database-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: database-inline
table-strategy:
standard:
sharding-column: order_id
sharding-algorithm-name: table-inline
sharding-algorithms:
database-inline:
type: INLINE
props:
algorithm-expression: ds$->{user_id % 4}
table-inline:
type: INLINE
props:
algorithm-expression: t_order_$->{order_id % 4}
你看,业务代码里,你依然写 SELECT * FROM t_order,ShardingSphere 会自动帮你路由到正确的库和表。
分库分表的新挑战
分库分表不是银弹,它带来了新问题:
分布式 ID:
order_id不能再用 MySQL 的AUTO_INCREMENT了,因为每个分片都有自己的自增 ID,全局不唯一。- 解法:使用 Snowflake(雪花算法)或 Redis 生成全局唯一 ID。我们用的是改进版雪花算法,结合了时间戳、机器 ID 和序列号,保证有序且唯一。
跨库查询:
SELECT * FROM t_order WHERE user_id = 123这种查询,如果按order_id分片,就需要扫全部 16 张表。- 解法:避免这种查询,或者将高频查询的数据冗余到 ES 中。
扩容困难:如果以后要从 16 张表扩容到 32 张,数据迁移是个大工程。
- 解法:初期设计时就要预留足够的分片数量,或者使用支持动态扩容的中间件(如 ShardingSphere-Proxy 配合存储过程,但复杂度高)。我们一开始就定了 64 个分片,够用三年。
第五波冲击:秒杀核心——库存扣减与超卖问题
前面都是基础设施优化,现在我们要谈业务核心:如何在高并发下,准确、快速地扣减库存,且不超卖?
错误示范:先查后减
// 伪代码,千万别说这是你写的
Product product = productMapper.selectById(id);
if (product.getStock() > 0) {
product.setStock(product.getStock() - 1);
productMapper.updateById(product);
}
这段代码在并发下必死无疑。两个线程同时读到 stock=1,都判断通过,然后都执行减一,结果库存变成 -1,超卖一个。
优化方案一:数据库乐观锁
UPDATE product SET stock = stock - 1, version = version + 1
WHERE id = #{id} AND stock > 0 AND version = #{version};
利用 stock > 0 作为条件,如果更新行数为 0,说明库存不足或已被他人抢走。这比先查后减好,但在高并发下,大量失败的事务会消耗数据库资源。
优化方案二:Redis 预减库存(推荐)
秒杀场景,99% 的失败请求应该在应用层被拦截,而不是打到数据库。
- 活动开始前:将库存加载到 Redis,设为 key-value。
redisTemplate.opsForValue().set("seckill:stock:" + productId, 100); - 用户请求到达:先在 Redis 中
DECR
