说到高并发,很多刚入行的朋友第一反应就是:“加机器啊!”或者“上Redis啊!”确实,这些是手段,但不是核心。真正的核心在于架构的演进逻辑。想象一下,你的电商系统在大促期间,每秒请求量从几十飙升至几万,MySQL那单核CPU瞬间红得像过年贴的春联,这时候你该怎么办?
今天我不给你堆砌枯燥的理论,咱们像老朋友聊天一样,把从单机数据库到分布式集群,再到缓存的那些坑,一层层剥开看。我会结合真实的业务场景和代码示例,让你不仅知道“怎么做”,更知道“为什么这么做”以及“不做会怎样”。
第一阶段:当单机MySQL扛不住时,读写分离是必经之路
假设你的应用初期只有一台MySQL服务器。随着用户量增长,写操作(Insert/Update)和读操作(Select)都在争抢资源。MySQL的InnoDB引擎虽然支持行级锁,但频繁的查询依然会让主库CPU飙升。
1.1 为什么需要读写分离?
读写分离的核心思想很简单:让擅长写的负责写,擅长读的负责读。
- 主库(Master):负责处理所有的写请求(事务、更新、删除),并同步数据到从库。
- 从库(Slave):负责处理所有的读请求,通过主从复制机制保持数据一致性。
1.2 架构实现与痛点
在早期,我们通常使用中间件如MyCat,或者直接在代码层面配置多个数据源。但在现代微服务架构中,我们更多使用ShardingSphere或自定义的数据源路由。
这里有个巨大的坑:数据延迟。
当你刚写完数据,紧接着去读,结果发现读的还是旧数据。这是因为主从复制是异步的(默认情况下)。在金融交易或库存扣减这种强一致性场景下,这是不可接受的。
解决方案: 对于强一致性的读(比如查订单状态),强制路由到主库;对于非强一致性的读(比如商品详情、首页推荐),可以路由到从库。
// 伪代码示例:简单的数据源路由策略
public DataSource getDataSource(boolean isWrite) {
if (isWrite) {
return masterDataSource; // 强制走主库
} else {
// 简单轮询从库,实际生产中需考虑从库负载和延迟监控
return slaveDataSourceList.get(ThreadLocalRandom.current().nextInt(slaveDataSourceList.size()));
}
}
专家建议: 不要盲目追求100%的读写分离。如果读多写少,且对实时性要求不高,读写分离效果显著。但如果写压力极大,主库本身就是瓶颈,那么读写分离只能缓解读的压力,无法解决写的瓶颈。这时,你需要进入下一阶段。
第二阶段:突破单机极限,分库分表的艺术
当单库单表的数据量超过千万级,索引效率下降,IO成为瓶颈,即使读写分离也救不了你。这时候,分库分表登场了。
2.1 垂直拆分 vs 水平拆分
- 垂直拆分:按业务模块拆分。例如,用户库、订单库、商品库分开。这解决了不同业务间的资源争抢问题。
- 水平拆分:将一个大表拆分成多个小表。例如,
order_0,order_1, …order_99。这是解决海量数据存储和高并发写入的关键。
2.2 分片键(Sharding Key)的选择
这是最核心的决策。选错了分片键,后续的数据迁移、跨库查询、热点数据问题会让你怀疑人生。
常见策略:
- 按UserID分片:适合C2C业务,保证同一用户的所有数据在同一分片,避免跨库Join。
- 按OrderID分片:适合B2C业务,订单流水均匀分布。
- 取模运算:
hash(user_id) % N,数据分布均匀,但扩容困难(需要重新哈希)。
2.3 实际代码实现:基于ShardingSphere的分片配置
现在业界主流推荐使用ShardingSphere-JDBC。它侵入性低,配置灵活。
# sharding-jdbc.yaml 示例配置
dataSources:
ds_0:
url: jdbc:mysql://localhost:3306/ds_0?serverTimezone=UTC&useSSL=false
username: root
password: 123456
ds_1:
url: jdbc:mysql://localhost:3306/ds_1?serverTimezone=UTC&useSSL=false
username: root
password: 123456
shardingRule:
tables:
t_order:
actualDataNodes: ds_${0..1}.t_order_${0..1} # 2个库,每个库2张表,共4张表
tableStrategy:
inline:
shardingColumn: user_id
algorithmExpression: t_order_${user_id % 2}
keyGenerator:
type: SNOWFLAKE
column: order_id
bindingTables:
- t_order, t_order_item
defaultDatabaseStrategy:
inline:
shardingColumn: user_id
algorithmExpression: ds_${user_id % 2}
关键点解析:
- 雪花算法(Snowflake):生成全局唯一的ID,保证ID有序且分散,避免热点。
- 绑定表:如果两张表经常Join,且分片规则相同,设为绑定表可以避免跨库Join,提升性能。
2.4 分库分表后的挑战
- 跨库Join:尽量避免。如果必须Join,先在一个库中查出ID列表,再在另一个库中批量查询。
- 分页查询:
LIMIT offset, size在大表中性能极差。建议使用“游标法”或“延迟双删”结合缓存。 - 数据扩容:分库分表后,扩容意味着数据重分布,这是一个浩大的工程。建议在初期设计时就预留足够的分片数量,或使用支持在线重分片的中间件。
第三阶段:缓存的神器与陷阱——击穿、雪崩、穿透
有了数据库的分片,QPS可能还是不够。这时候,Redis等缓存介质介入。但缓存不是银弹,它带来了新的问题。
3.1 缓存穿透
现象:查询一个根本不存在的数据,缓存中没有,数据库中也没有,每次请求都打到数据库。
原因:恶意攻击或业务逻辑错误。
解决方案:
- 布隆过滤器(Bloom Filter):在访问缓存前,先用布隆过滤器判断Key是否存在。如果不存在,直接返回。
- 缓存空值:即使数据库没有,也将
null存入缓存,设置较短的TTL。
// 伪代码:缓存空值策略
public Object getFromCacheOrDB(String key) {
Object value = redisTemplate.opsForValue().get(key);
if (value == null) {
value = db.query(key);
if (value == null) {
// 缓存空对象,防止穿透
redisTemplate.opsForValue().set(key, "", 60, TimeUnit.SECONDS);
return null;
}
redisTemplate.opsForValue().set(key, value, 3600, TimeUnit.SECONDS);
}
return value;
}
3.2 缓存击穿
现象:某个热点Key突然过期,此时大量请求同时到达,瞬间击穿到数据库。
原因:热点Key集中过期。
解决方案:
- 互斥锁(Mutex Lock):只有一个线程去查库并重建缓存,其他线程等待。
- 逻辑过期:不设置物理TTL,而是在Value中嵌入逻辑过期时间。后台异步刷新,读取时如果发现逻辑过期,触发异步重建。
// 伪代码:互斥锁防击穿
public Object getDataWithLock(String key) {
Object value = redisTemplate.opsForValue().get(key);
if (value != null) {
return value;
}
String lockKey = "lock:" + key;
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (locked) {
try {
// 双重检查,防止等待期间其他线程已重建
value = redisTemplate.opsForValue().get(key);
if (value != null) {
return value;
}
// 查数据库
value = db.query(key);
// 写入缓存
redisTemplate.opsForValue().set(key, value, 3600, TimeUnit.SECONDS);
} finally {
redisTemplate.delete(lockKey);
}
} else {
// 等待重试
Thread.sleep(50);
return getDataWithLock(key);
}
return value;
}
3.3 缓存雪崩
现象:大量Key在同一时间过期,或者Redis宕机,导致所有请求打到数据库,造成数据库崩溃。
原因:缓存集体失效或缓存服务故障。
解决方案:
- TTL随机化:给缓存过期时间加上一个随机值,避免集中过期。
- 高可用架构:Redis集群、哨兵模式,确保缓存服务不宕机。
- 限流降级:当数据库负载过高时,直接返回默认值或友好提示,保护数据库。
第四阶段:综合实战——高并发秒杀系统优化指南
让我们把这些技术点串联起来,看一个真实的秒杀场景。
场景描述: 某电商平台进行iPhone 15秒杀,预计峰值QPS 50,000。
4.1 架构演进路线图
- 静态化:秒杀页面的HTML、JS、CSS全部CDN静态化,减少后端请求。
- 网关限流:在API网关层,针对秒杀接口进行限流,拦截无效请求。
- Redis预减库存:
- 活动开始前,将库存加载到Redis(Atomic Decr)。
- 用户请求到达时,先在Redis扣减库存。
- 扣减成功,发送消息到MQ;扣减失败,直接返回“已售罄”。
- 异步削峰:
- MQ消费者慢慢处理订单创建、支付流程。
- 将结果异步写入MySQL。
- 数据库最终一致性:
- MySQL中记录订单状态。
- 如果Redis扣减成功但MQ消费失败,需要有补偿机制(定时任务扫描)。
4.2 关键代码片段:Redis扣减库存
@Service
public class SeckillService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private RabbitTemplate rabbitTemplate;
/**
* 秒杀入口
*/
public SeckillResult seckill(Long userId, Long productId) {
String stockKey = "seckill:stock:" + productId;
String userKey = "seckill:user:" + productId + ":" + userId;
// 1. 幂等性检查:防止重复下单
Boolean isExist = redisTemplate.opsForValue().setIfAbsent(userKey, "1", 10, TimeUnit.MINUTES);
if (!isExist) {
return new SeckillResult(false, "请勿重复点击");
}
// 2. 扣减库存 (原子操作)
Long stock = redisTemplate.opsForValue().decrement(stockKey);
if (stock < 0) {
// 库存不足,删除用户标记以便下次还能尝试(或者根据业务需求保留)
redisTemplate.delete(userKey);
return new SeckillResult(false, "库存不足");
}
// 3. 扣减成功,发送消息到MQ异步下单
try {
SeckillMessage message = new SeckillMessage(userId, productId);
rabbitTemplate.convertAndSend("seckill.exchange", "seckill.routing.key", message);
return new SeckillResult(true, "排队中,请等待结果");
} catch (Exception e) {
// 消息发送失败,回滚库存
redisTemplate.opsForValue().increment(stockKey);
redisTemplate.delete(userKey);
return new SeckillResult(false, "系统繁忙");
}
}
}
4.3 数据库层面的优化
即使有MQ削峰,MySQL写入压力依然存在。
- 分库分表:订单表按
user_id或order_id分片。 - 批量插入:MQ消费者攒批插入,比如每100条插入一次。
- 索引优化:确保查询订单状态的字段有合适索引,避免全表扫描。
- 连接池调优:使用HikariCP,合理设置最大连接数,避免数据库连接耗尽。
第五阶段:给小朋友也能听懂的“图书馆比喻”
为了让你更好地向团队成员或非技术人员解释这套架构,我们可以用大型图书馆来打比方:
- 单机MySQL:就像一个小图书室,只有一个管理员(CPU)。大家都来找书(读)和还书(写),管理员忙得脚不沾地,队伍排到了门口。
- 读写分离:老板招了两个管理员。一个专门负责借书还书(写),一个专门负责找书(读)。但是,刚还回去的书,找书的管理员可能还没看到,因为他俩沟通有延迟(主从延迟)。
- 分库分表:图书馆太大了,一本书放在哪不知道。于是把图书馆分成几个区(分片),每个区有自己的目录。找书前先查目录(Hash计算),直接去对应的区找。这样,找书的效率大大提升了。
- 缓存(Redis):在图书馆门口设了一个“热门书籍推荐板”。大家都想看《哈利波特》,管理员不用每次都去书架找,看一眼推荐板就知道还有几本。
- 穿透:有人问“《阿凡达3》在哪?”,推荐板上没有,书架上也没有。管理员得跑一趟确认没有,然后告诉这人没有。如果很多人问,管理员累死了。解决办法:在推荐板上贴一张“暂无此书”的纸条(缓存空值)。
- 击穿:推荐板上说“《哈利波特》只剩1本”,结果瞬间来了100个人要买。管理员手忙脚乱。解决办法:管理员拿出一块牌子挡住入口(互斥锁),一个一个处理。
- 雪崩:推荐板上所有的热门书同时到期,大家又涌向书架。解决办法:给每本书的到期时间加个随机数,别扎堆。
结语:没有最好的架构,只有最适合的架构
回顾整个过程,从读写分离到分库分表,再到多级缓存和异步解耦,每一步都是为了解决特定阶段的瓶颈。
记住几点黄金法则:
- 不要过早优化:在单机还能扛住的时候,不要急着上分库分表,维护成本极高。
- 数据一致性优先于可用性:在金融领域,宁可报错也不能丢数据。
- 监控先行:没有监控的架构就是盲人摸象。务必接入Prometheus + Grafana,实时监控QPS、RT、错误率、JVM内存、数据库连接池等指标。
- 预案!预案!预案!:任何高并发系统都要考虑最坏情况:Redis挂了怎么办?MQ堵了怎么办?数据库主库宕机怎么办?要有自动切换和降级的预案。
高并发处理是一场持久战,技术选型没有标准答案,只有基于业务场景的最佳平衡。希望这篇指南能为你构建稳健、高效的高并发系统提供清晰的思路。如果你在实战中遇到具体的性能瓶颈,欢迎随时交流,我们一起拆解分析。
