尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

高并发架构五大方案:缓存、队列、分库分表与限流熔断实战

高并发架构五大方案:缓存、队列、分库分表与限流熔断实战 1. 高并发瓶颈到底是什么先看请求在系统中经历了什么1.1 瓶颈往往不在代码逻辑而在资源争抢有一次大促压测我盯着监控大屏数据库 CPU 曲线从 30% 一路顶到 98%慢查询队列从 0 涨到几千网关的超时告警像弹幕一样刷屏。第一反应是数据库机器太弱于是紧急扩了 CPU 和内存结果只撑了五分钟曲线又拉满。后来一步步查下去才发现真正卡死系统的根本不是 CPU而是数据库连接数被占满后面所有请求都在排队等连接。这个场景在互联网公司的凌晨三点的重演频率远比你想象中高。很多人一听到高并发下意识就是加机器、扩配置但高并发系统的瓶颈本质上是资源争抢——不是单个请求处理得慢而是大量请求同时争抢某个有限资源把系统拖垮。这个有限资源可能是数据库连接池、Tomcat 线程池、Redis 的网络带宽、磁盘 IO甚至是 JVM 堆内存。理解这一点很重要。一个接口平均响应时间 100msQPS 到 1000那某一瞬间系统里同时在处理的请求数大约是 1000 × 0.1 100 个请求。如果响应时间因为慢 SQL 变成 1 秒同样的 QPS 就需要系统同时容纳 1000 个请求。线程池、连接池一旦被占满后续请求就是排队等等不到就超时。这时候你加再多的 CPU 都没用问题在同时等待的资源数量而不在单次计算速度。所以排查高并发瓶颈第一步不是看哪台机器配置低而是先回答一个问题请求到底堵在哪是网络上、线程池里、连接池里还是数据库的锁上1.2 从一次用户请求出发拆解四大瓶颈节点一次典型的请求要经过DNS 解析 → 接入层Nginx/网关→ 应用服务 → 缓存 → 数据库 → 外部依赖。这条链路上每个环节都可能成为压垮系统的最后一根稻草。我按实际运维经验把它们归成四类连接数瓶颈长连接服务最典型比如 IM 聊天、WebSocket 推送。每个客户端维持一个 TCP 连接连接本身不消耗太多 CPU但连接数一多文件句柄、线程、内存都会被打满。Nginx 默认 worker_connections 往往不够用应用服务器的线程池也会被空闲连接长期占用。CPU 计算瓶颈加解密、序列化、正则匹配、GC 频繁。这类问题表现为 CPU 100%但数据库和 IO 都很闲。常见于日志打太狠、RSA 加解密放在热点路径、或者正则表达式写得太差导致回溯爆炸。IO 瓶颈数据库磁盘读写慢、Redis 网络往返多、文件存储慢。传统机械盘随机读 10ms 级别而内存访问是纳秒级。数据库一旦出现大量随机 IO整体 RT 就会涨得很难看。锁与竞争瓶颈数据库行锁、分布式锁、本地锁、乐观锁冲突。并发一高锁等待时间直线上升。这个最隐蔽监控上可能 CPU 不高、IO 不高但接口就是慢因为线程全在等锁。拿 IM 场景来说最容易踩的就是第一个。用户量上来之后长连接数量几十万单机连接数打满心跳消息在高峰期把带宽和 CPU 都吃光。这时候你只做应用层的业务优化是没用的必须在接入层做连接管理、心跳优化和消息合并推送。1.3 吞吐量与延迟一个公式看清并发上限做高并发改造我建议先把 Littles Law 刻在脑子里并发数 QPS × 平均响应时间。这个公式看似简单但能推导出很多优化方向。假设一个接口 RT 是 120ms系统里同时只能容纳 200 个请求那它的理论吞吐上限就是 200 / 0.12 ≈ 1666 QPS。你想把吞吐提到 5000要么把 RT 降下来要么把系统同时容纳的请求数提上去。缓存为什么是性价比之王就是因为它在降 RT。接口走一次数据库查询平均 5ms但如果在本地缓存命中可能 0.1ms 就返回了。RT 从 5ms 降到 0.1ms同样的并发数吞吐量能涨 50 倍。消息队列为什么能削峰因为它把同步请求变成了异步请求让系统同时处理的请求数不再跟随洪峰暴涨。限流为什么能保命因为它直接限制进入系统的并发数防止资源耗尽。这套分析框架比任何具体技术方案都重要。你看一个系统先算算当前并发数是多少再判断瓶颈在 RT 还是在并发容量。搞反了方向方案越做越重效果还很差。2. 方案一缓存——把热点数据拦在数据库前面2.1 缓存分几层分别解决什么问题缓存是解决高并发读的第一板斧也是最容易快速见效的手段。但缓存不是只有 Redis 一种形态越靠近客户端延迟越低数据覆盖范围越小越靠近数据源一致性越好延迟越高。缓存层级典型实现延迟量级适合场景浏览器缓存HTTP Cache、LocalStorage0ms静态资源、不常变的页面CDN阿里云 CDN、Cloudflare10~50ms图片、视频、静态文件进程内缓存Caffeine、Guava、本地 Map亚毫秒单机热点数据、配置项分布式缓存Redis、Memcached0.1~1ms跨节点共享热点数据数据库缓存MySQL Buffer Pool1ms 级数据库内层自动缓存我见过不少团队上来就把所有缓存都塞进 Redis。其实对于某台机器自己频繁读且允许轻微不一致的数据放 Caffeine 就够了省去网络开销也减轻 Redis 压力。Redis 适合放多个实例共享的热点数据比如用户登录态、商品库存、在线状态。而图片、CSS、JS 这类几乎不变的静态资源一定要推到 CDN否则这些流量会白白打到应用层。我做高并发改造时习惯先问一个问题这些读请求有多少是可以在更前面拦住的如果 80% 的都是同一个商品详情页的图片那第一优先不是优化数据库而是把这些图片推到 CDN。这个思路和很多人的直觉相反但性价比极高。2.2 缓存穿透、击穿、雪崩的攻防细节缓存做得越多越要小心三个经典问题穿透、击穿、雪崩。这三个问题把无数团队按在地上摩擦过。缓存穿透是指查询一个根本不存在的数据缓存里没有数据库里也没有。每次请求都直接打到数据库。如果有攻击者恶意构造一批不存在的 ID数据库瞬间被压垮。解决思路有两个一是把空结果也缓存起来TTL 设短一点比如 5 分钟二是用布隆过滤器把所有可能存在的数据 ID 预判一遍布隆过滤器说不存在就直接返回空说存在才放行。布隆过滤器有一个好处不会漏判但可能误判所以它能挡住大部分入侵请求并且不要求 100% 精确。缓存击穿指的是某个热点 key 在过期的一瞬间大量请求同时发现缓存没命中全部穿透到数据库。典型的例子是某商品处于秒杀状态商品的库存数据是超级热点。等缓存一过期几千个请求同时打过去数据库直接被打瘫。常用的方案是互斥锁重建当缓存没命中时不是所有请求都去查库而是先获取一个分布式锁只有拿到锁的请求去查库并回填缓存其他请求短暂等待后再读缓存。另一种做法叫逻辑过期缓存里设置一个逻辑过期时间后台异步刷新前台请求永远能读到旧数据不会出现缓存空窗。缓存雪崩是指大量 key 在同一时间集中过期或者 Redis 直接宕机导致所有请求同时打到数据库。解决办法也很直接过期时间加一个随机值防止集体到期同时做多级缓存兜底Redis 挂了还有本地缓存更保险的是给数据库加个限流在极端情况下只让一部分请求进来其余走降级逻辑。2.3 缓存一致性先更新 DB 还是先删缓存缓存最麻烦的不是命中率而是数据一致性。大多数团队采用 Cache Aside 模式读请求先查缓存没命中就查库回填写请求更新数据库后删除缓存让下一次读去拉最新数据。为什么更新 DB 后不直接更新缓存因为写操作频率往往比读低如果每次写都更新缓存缓存里可能存了很多只被写过但没有被读过的数据白白浪费内存。这里有一个并发窗口问题先更新 DB再删缓存如果在删除缓存之前有一个读请求把旧数据回填到缓存那缓存里就会残留旧值。更稳妥的实践是延迟双删更新 DB 后删除一次缓存过几百毫秒再删一次确保回填的旧数据能被清掉。还有一招更彻底——用 Canal 订阅 MySQL binlog异步把最新数据同步到缓存或搜索引擎应用层完全不管缓存更新。但我也要泼一盆冷水不是所有数据都需要强一致。商品摘要、排行榜、用户昵称这类读多写少且允许短暂延迟的数据一个 10 分钟的 TTL 加异步刷新就够用了。真正强一致的数据比如账户余额、库存扣减压根不建议用通用 Redis 缓存兜底老老实实走数据库事务加原子更新。分清数据的一致性等级比纠结先删缓存还是先更新数据库重要得多。3. 方案二消息队列削峰填谷——给洪峰一个缓冲区3.1 为什么同步调用扛不住秒杀很多业务系统在平时的流量下活得好好的一到秒杀、抢购、集中推送就雪崩核心原因就是同步调用链路太长。用户的一次下单请求如果同步调用了订单服务、库存服务、积分服务、短信服务那用户感知的 RT 就是所有服务 RT 的总和。更糟糕的是高峰期下游每个服务都会同时收到近乎等量的流量整个系统的流量被放大了链路倍数。举个例子下单链路同步调 3 个服务平均每个服务 20ms高峰期 QPS 到 1000。那订单服务收到 1000 QPS积分服务也收到 1000 QPS短信服务还是 1000 QPS。一旦某个服务出现抖动编写最差的环节会把整个链路拖住因为所有调用方都在同步等地返回。消息队列在这里起的作用是削峰填谷。瞬时流量进来后先落到队列后端消费者按照自己的处理能力一点一点消费。订单服务只需要把订单创建成功这个事件发到队列里积分服务和短信服务作为消费者各自找时间去处理。下单核心链路从原来的 3 个依赖变成 1 个RT 骤降下游流量也被削平。3.2 队列选型与可靠性权衡选型之前先明确需求。消息队列不是越强越好而是匹配场景。队列吞吐能力功能特性适合场景运维成本Kafka单机几十万条/秒高吞吐、分区有序日志采集、大数据管道、消息推送中RocketMQ十万级/秒事务消息、延迟消息、顺序消息金融级业务、订单状态机偏高RabbitMQ万级/秒轻量、灵活、路由能力强中小团队、简单异步任务低我做秒杀类系统时更偏好 Kafka原因只有一个它能扛住瞬时洪峰。Kafka 的页缓存机制让写入吞吐极高消息堆积能力也很强百万条消息堆积对它来说不算什么。但 Kafka 的缺点是功能相对底层如果你需要事务消息、精确的延迟消息那 RocketMQ 更合适。中小型团队如果每天消息量就几十万条RabbitMQ 完全够用别为了架构先进把运维复杂度拉满。可靠性上我的经验是默认接受至少一次投递的语义。也就是说消息可能重复但尽量不丢。生产端开启 ack 确认broker 落盘刷盘消费端关闭自动提交手动 ack失败重试重试多次后进入死信队列。消费端必须做成幂等的——同一个事件处理两次和一次的结果一样。比如加积分操作要做成依据消息唯一 ID 判断是否已经执行过。3.3 削峰之外解耦与异步化的收益除了削峰消息队列更像一条护城河。我做过一个电商系统下单完成后要发短信、加积分、更新搜索引擎索引、给运营发报表。如果所有逻辑都写在订单服务里每加一个下游需求订单服务就要发一次版耦合越来越重。后来引入 MQ订单服务只发布一个订单创建成功事件其他系统各自订阅订单服务不再关心下游到底有几个。IM 场景也很典型。用户发送一条消息在线用户需要实时推送离线用户要等上线后拉取。实时推送不能走队列否则延迟不可控但离线消息完全可以丢到 Kafka由推送消费者慢慢处理。这样即使瞬时消息量很大推送通道也不会被冲垮离线消息靠着队列的堆积能力撑住。做异步化有个必须注意的边界不是所有逻辑都适合异步。用户下单后如果立刻想知道是否下单成功这个结果需要同步返回但下单成功后的积分变化完全可以异步。把核心写路径和外围非核心路径分清楚消息队列的价值才能真正体现。4. 方案三数据库拆分的边界与代价——读写分离、分库分表4.1 读写分离主从延迟这一关怎么过大部分业务系统其实是读多写少读可能占 80% 以上。这种场景下读写分离是性价比很高的第一步。主库负责写入从库负责读取一主多从横向扩展读能力主库连接数压力也大幅下降。但读写分离有一个绕不开的坑主从延迟。MySQL 默认复制往往是异步的从库落后主库几百毫秒是常事。简单粗暴地把所有读都切到从库就会出现用户刚下单成功刷新订单列表却看不到新订单这种诡异问题。我的做法是把读请求分两类关键读和非关键读。关键读指用户必须立刻看到自己刚写入的数据比如下单后订单详情、修改密码后的新会话这类请求强制走主库非关键读比如商品列表、用户主页信息允许短暂延迟放从库。如果从库延迟超过设定阈值监控告警甚至自动把流量切回主库。数据库连接数也是很隐蔽的问题。默认 max_connections 常常只有 151应用节点一多每个连接池 20 个连接十台机器就把主库连接占满了。读写分离之后从库承担大量读连接主库的连接数压力自然就缓解了。这部分收益很多人一开始没想到。4.2 分库分表分片键选择和扩容的麻烦事读写分离解决的是读太多的问题但如果单表数据量太大比如一张订单表几个亿索引即使建了B 树的层数也在增加写入时也会有更严重的锁竞争。这时候就得考虑分库分表。分库分表最核心的决策是分片键。这个键决定了所有查询能不能命中单一分片。订单表按 buyer_id 分片那查某个用户的订单列表非常高效直接路由到固定分片。但如果运营想查某个商家的订单就需要跨所有分片聚合慢得离谱。这时候要么再建一张按 seller_id 分片的分表要么把数据同步到搜索引擎。扩容是另一个大坑。如果从 4 个库扩到 8 个库取模路由会导致绝大多数数据需要重分布这个迁移过程比想象中痛苦得多。实践中常用的方案是提前按较大规模分片比如直接规划 1024 个逻辑分片物理库从 4 个逐步增加到 8 个每个逻辑分片映射到物理库扩容时只迁一部分逻辑分片。分布式 ID 也要提前设计雪花算法是主流全局唯一且趋势递增。但分库分表真的能扛住高并发吗它能因为它把单库的连接数和 IO 压力分散到多台机器上。这也是为什么它往往放在缓存和队列之后——因为分库分表解决的是有状态数据的容量和并发问题属于穿透缓存和队列之后的最后防线。4.3 什么时候不该拆分先做减法再做加法分库分表是最复杂、最昂贵、最不可逆的方案。我见过一个团队业务高峰期每分钟也就几千请求数据量几百万行却提前把用户表拆成 32 个库。结果 join 要跨库、事务要搞分布式事务、查询逻辑复杂到新人根本不敢动开发效率暴跌而实际收益几乎为零。所以我的经验是先做相反方向的事。第一SQL 和数据访问层是否已经优化到位有没有 SELECT *、有没有索引失效、有没有慢查询没治理第二数据是否可以做冷热分离比如订单只查最近 3 个月那把历史订单归档到独立库表线上热表的数据量瞬间小一个数量级。第三是否能用缓存扛住热点读如果 20% 的数据扛了 80% 的流量先用缓存把这部分热点挡住。当这些都做完了单表数据量仍然到了几千万甚至亿级写入并发仍然扛不住再分库分表。那样的话分片产生的复杂度至少是换来确定性收益的。先减后加这个顺序不能反。5. 方案四水平扩展与无状态化——加机器不是复制粘贴那么简单5.1 无状态化改造是水平扩展的前提很多人对水平扩展的理解就是把应用部署到多台服务器加个负载均衡就完事。实际一扩容就发现用户在 A 机器上登录成功下一次请求被路由到 B 机器登录状态直接没了因为 session 存在了 A 的本地内存里。定时任务在每台机器上同时执行数据被重复处理。A 机器本地缓存的数据和 B 机器完全不同用户体验被割裂。这些都是有状态带来的问题。水平扩展的前提是把服务改造成无状态会话状态外置到 Redis配置放到配置中心任务调度加分布式锁保证单节点执行。本地缓存并不是完全不能用对于允许短暂不一致的配置数据用本地缓存当一级缓存Redis 当二级缓存是可行的。但前提是你能容忍不同机器之间短暂的数据差异。无状态化看起来简单落地时阻力很大。很多老代码里 session 管理、本地静态变量、文件上传路径都是隐式状态需要一个个排查。改造成无状态后应用层基本上就变成了一堆可以随意增减的计算节点水平扩展才算真正成立。5.2 负载均衡与会话保持请求到底去哪台机器无状态化做好之后负载均衡可以随意轮询如果没有完全无状态就需要考虑粘滞会话或一致性哈希。轮询是最简单的适合所有节点处理能力相近、请求本身不依赖用户上下文的情况。加权轮询可以应对不同机器配置不一样的情况。最少连接适合长耗时请求较多的场景避免把新请求都压到正在处理慢请求的节点上。一致性哈希则常用于有粘滞需求的场景。比如 IM 长连接客户端和接入节点之间的连接一旦建立不能频繁漂移。同一用户的请求最好固定路由到同一台接入机这样消息转发和连接管理都简单。一致性哈希能让节点扩缩容时只影响一小部分映射关系但节点故障时该节点上的连接还是要断开需要客户端重连和协调层重新分配。不管用哪种算法健康检查必须做深。不要只检查进程是不是还活着要提供一个接口检查依赖的 Redis、数据库连接池是不是正常。否则负载均衡会把流量打到半死不活的节点上用户还是超时。5.3 微服务化与按流量特征扩展水平扩展做到后期会自然遇到一个问题不同业务模块的流量特征不一样。用户的登录服务可能只有几百 QPS但消息推送服务可能有几万 QPS。如果它们耦合在同一个单体应用里扩容时只能整包扩成本高还可能影响关键模块。微服务化的本质不是把代码拆得越碎越好而是把不同流量特征、不同扩展性需求的模块拆开各自独立部署和扩容。消息服务可以单独扩到 20 台用户服务保持 5 台资源利用率更高。但在拆之前要想清楚微服务引入了网络调用、分布式事务、服务发现、调用链追踪这么多复杂度是否真的值得我见过把用户模块拆成 8 个服务结果内网调用延迟反而比单体更高的案例。水平扩展也有终点。真正最难扩展的是有状态的数据层。缓存、队列、分库分表这些方案本质上都是为了让数据库这个终极有状态节点的请求量降下来或者把它的流量分散到多个片。架构演进到这一步五大方案就开始交织在一起了。6. 方案五限流、熔断与降级——先保住系统再谈性能6.1 限流算法令牌桶、漏桶与滑动窗口的实战选择高并发改造做得再好也挡不住流量超出预估。流量入口处必须有限流这是系统的保命符。算法核心思想优点缺点典型场景令牌桶以固定速率往桶里放令牌请求要拿到令牌才能放行允许一定突发流量突发可能超过下游容量网关入口、秒杀入口漏桶请求以固定速率流出超出桶容量的直接拒绝输出速率恒定保护下游无法应对突发流量对接第三方慢接口滑动窗口把一个时间段拆成多个小窗口统计最近一个窗口的请求数平滑避免临界突发实现稍复杂接口级限流具体选哪个取决于下游能承受什么。如果下游数据库能不能接受突发如果能令牌桶更合适如果不能漏桶更稳。落地时还要区分单机限流和分布式限流。单机限流用 Guava RateLimiter 或者 Sentinel 的本地模式就行性能高分布式限流需要把计数器放到 Redis用 Lua 脚本保证原子性适合网关层统一控流。限流阈值不能拍脑袋。我的习惯是每个接口上线前都要做单机压测找到性能拐点。比如压测发现单机 1000 QPS 时 CPU 已经到 90%那生产环境单机限流就设 700留 30% 余量。原因很简单网络抖动、GC、突发流量都会让系统在到达压测极限之前就撑不住。6.2 熔断不是甩锅是保护下游熔断这个概念拿保险丝类比就很好理解。家里某个电器短路了保险丝先断掉保住了全屋的电路。微服务架构里一个下游服务变慢或不可用如果不熔断调用方会一直等、一直重试很快把调用方的线程池也占满故障像滚雪球一样波及上游。这就是雪崩。熔断器的核心参数有三个错误率阈值、慢调用阈值、熔断时长。比如连续 5 秒内错误率超过 50%或者有 90% 的请求 RT 超过 2 秒就触发熔断。熔断后请求快速失败不再打到下游给下游恢复的时间。等熔断时间过去进入半开状态放少量请求试探成功率达到阈值才关闭熔断。很多人把熔断当成出了问题直接报错这是误解。熔断的真正价值是保护整个系统把故障范围隔离在一个小圈子内。配合超时设置一起用效果更明显。如果调用下游不设置超时线程池会被慢请求占满有了超时再加熔断才能让系统在部分依赖故障时仍然健康。6.3 降级开关把非核心功能优雅地放到后面限流是挡住外面的流量熔断是挡住下游的故障降级则是主动做取舍。当系统压力过大或者为了优先保障核心链路时关掉一些非核心功能把资源让给最重要的请求。降级要提前设计好开关。配置中心里预置开关位比如推荐列表降级为默认列表、积分发放改为异步批量、短信通知暂时关闭。故障发生时运维只需要改一个配置项不用重新发版。我见过最好的降级设计是为每个接口标注核心等级核心链路不轻易降级非核心链路优先降级。比如 IM 聊天场景核心是消息收发第三方推送通道出问题时就降级为应用内长连接推送加离线消息队列先把消息送出去第三方推送等恢复后再补发。降级开关必须定期演练。很多团队配置了降级开关但从来没试过真到故障时才发现配置平台本身也挂了。把降级演练纳入大促前的常规流程这比多做几次模拟压测更管用。7. 五大方案的落地顺序与组合策略7.1 先诊断瓶颈再选方案五大方案不是什么都上而是对症下药。我见过最可惜的情况是团队花一个月上了消息队列结果瓶颈在慢 SQL队列对整体性能毫无帮助。所以做任何改造之前先回答三个问题系统里哪个节点 RT 最高哪个资源最先被打满哪个依赖最不稳定用 APM 工具或者调用链系统看一遍全景接口 RT 都花在哪、数据库慢查询有哪些、Redis 命中率如何、线程池活跃度高不高。再结合压测把流量从低到高压上去观察哪个指标先报警。CPU 先满就先优化计算密集逻辑连接数先满就先调整连接池或做读写分离IO 先满就上缓存。压测还有一个容易被忽视的作用能提前发现那些只在流量高时才暴露的问题比如锁竞争、GC 耗时暴涨、连接池泄漏。这些问题在低流量下根本看不出来。7.2 组合案例IM 长连接场景的高并发改造如果只看单一方案容易忽略系统性思维。我用 IM 场景把五大方案串起来说一下。IM 的瓶颈主要有四个长连接数量大、消息同步读放大、离线消息堆积、第三方推送不稳定。首先解决连接规模问题接入层拆成无状态网关和有状态接入层网关用一致性哈希把同一个用户固定路由到同一台接入机接入机可以水平扩展。用户在线状态、会话信息放 Redis不占应用内存。第二步热点数据分层缓存好友列表、群成员信息用本地缓存加 Redis 扛住高频读。第三步消息发送走 Kafka在线用户由实时消费者推送离线用户存起来上线后拉取。消费者按推送通道的吞吐量限流避免把推送通道打爆。第四步历史消息按 user_id 分库分表同时做冷热分离热数据放 MySQL全量归档到其他存储。第五步对第三方推送通道加熔断和降级通道抖动时不阻塞主链路。这套组合的本质是接入层可扩展热点读被缓存挡掉突发量被队列削平有状态数据被分片分散故障依赖被熔断和降级隔离。7.3 我的落地顺序建议如果是个从零开始的团队我会按这个顺序做先做超时、限流、熔断、降级这四件套保证系统在异常流量下不会雪崩。然后优化数据库把索引和慢 SQL 清一遍给读多写少的热点数据加缓存。接着把非核心链路用 MQ 异步化解耦并削峰。数据量真的到了千万级、上亿级再考虑读写分离和分库分表。最后才是微服务化的水平扩展。做高并发改造这些年我最深的体会是方案不是越先进越好而是越贴近当前瓶颈越好。很多团队卡在中间状态不是因为技术不会而是没有把瓶颈分析清楚。先把请求到底堵在哪想明白再决定上哪个方案五大方案自然就有了优先级。
返回列表