跳转到内容

并发、缓存与异步

系统变慢通常不是因为“代码不够快”这一句话,而是因为某处正在等待:等 CPU、等内存、等锁、等磁盘、等数据库、等网络,或者等前面排队的任务。并发、缓存、队列和限流都在处理这些等待,只是位置和代价不同。

  • 延迟(latency):一次操作从发出到完成用了多久。
  • 吞吐(throughput):单位时间能完成多少个操作。
  • 并发度(concurrency):同一时刻有多少操作处于进行中。
  • 并行度(parallelism):同一时刻有多少操作真的同时执行。

异步请求不一定并行执行。一个 CPU 核心可以在多个请求之间切换;如果一个请求执行长循环,其他请求仍会等待。

系统负载升高时,请求不会线性变慢,而会先进入队列。队列变长后,等待时间迅速增加,最终超时、重试又制造更多请求,形成正反馈故障。

可以用一个简化关系估算并发需求:

Little 定律:并发数 ≈ 到达速率 × 平均驻留时间

例如每秒收到 200 个请求,每个请求平均处理 0.5 秒,则平均约有 100 个请求同时在系统内。峰值还要考虑突发、长尾和依赖变慢。

平均值经常掩盖问题。一个接口平均 100 ms,但 1% 请求要 5 秒,用户仍会明显感到卡顿。观察 p50、p95、p99 和最大值,并明确统计窗口与样本量。

  • 多进程:隔离强,崩溃影响较小,但进程间通信和内存成本更高。
  • 多线程:共享地址空间,适合 CPU 并行,但共享状态需要锁和其他同步。
  • 事件循环/协程:少量线程管理大量 I/O 等待,适合高并发网络服务,但阻塞调用会拖住整个事件循环。

一种模型没有绝对优势。CPU 密集任务需要真正的并行计算;大量慢网络请求更依赖非阻塞 I/O;隔离要求高的任务可能适合独立进程或容器。

竞态发生在结果依赖不可控的执行顺序:

线程 A:读取余额 100
线程 B:读取余额 100
线程 A:写入 100 - 30 = 70
线程 B:写入 100 - 50 = 50

最终只扣了一笔。锁、原子操作、数据库条件更新或串行化队列可以解决不同场景的问题,但都要明确锁的范围和失败时的状态。

加锁过多会降低并发,并可能导致死锁。死锁需要同时满足互斥、持有并等待、不可抢占和循环等待等条件;预防方式包括固定锁顺序、设置等待超时、减少锁粒度,或改用无共享设计。

一个请求超时后,后台工作不一定自动停止。如果客户端已经放弃,服务端仍在查询数据库、调用第三方或占用线程,资源会继续消耗。

取消信号应从入口沿调用链传播,并给每一步设置合理的超时。超时值不能拍脑袋:要基于依赖延迟分布和业务可接受等待,而不是把所有层都设成 30 秒。

缓存用“可能旧一点的数据”换更低的延迟、更少的数据库压力和更高的吞吐。引入缓存前先回答四个问题:

  1. 缓存什么数据,键是什么?
  2. 数据允许旧多久?
  3. 数据变化时,谁负责让缓存失效?
  4. 缓存不可用或未命中时,系统怎样降级?
层 典型对象 特点
浏览器 静态资源、API 响应 离用户最近,受 Cookie、CORS 和缓存头影响
CDN 图片、脚本、公开内容 边缘节点缓存,需处理回源和刷新
应用进程内 配置、热点对象 最快,但多实例之间可能不一致
分布式缓存 会话、查询结果 多实例共享,增加网络和一致性成本
数据库缓存 页、查询计划 数据库内部管理,应用通常不可直接控制
  • Cache-Aside:应用先查缓存,未命中再查数据库并回填。
  • Read-Through:缓存层自己负责未命中时加载。
  • Write-Through:更新数据时同时更新缓存。
  • Write-Behind:先更新缓存,异步写回数据库,可能丢数据。

Cache-Aside 最常见,但“先改数据库还是先删缓存”存在短暂不一致。删除缓存通常比直接更新更稳妥;还要处理并发读写导致旧值重新回填的问题,例如延迟双删、版本号或订阅变更事件。

问题 现象 防护
缓存穿透 查询不存在的键,每次都打数据库 空值缓存、参数校验、布隆过滤器
缓存击穿 热点键过期,大量请求同时回源 互斥重建、逻辑过期、预热
缓存雪崩 大量键同时过期或缓存整体不可用 随机 TTL、分层缓存、降级与限流
热点倾斜 单个键压垮一个节点 分片热点、请求合并、限流
陈旧数据 写入后仍读到旧值 版本、失效、短 TTL、业务容忍度

缓存键必须包含区分结果的输入,例如租户、语言、权限范围、查询参数和版本。遗漏维度会把一个用户的数据错误地给另一个用户,这是安全问题,不只是缓存问题。

缓存不是数据库。需要强一致、可恢复的关键状态应持久化;缓存可以丢失。缓存命中率也不是越高越好,错误的命中可能让用户长期看到旧数据。

4. 限流、背压与过载保护

跳转到“4. 限流、背压与过载保护”

限流限制单位时间的请求数或资源消耗,保护自己和上游。

常见算法:

  • 固定窗口:每分钟一个计数,简单,但窗口边界可能瞬间放过两倍流量。
  • 滑动窗口:更平滑地统计一段时间,状态和计算成本更高。
  • 令牌桶:按速率补充令牌,允许一定突发。
  • 漏桶:按固定速率处理,平滑输出,但可能增加等待。

限流维度可以是 IP、用户、租户、API Key、接口或全局。只按 IP 可能误伤共享出口,只按用户又挡不住匿名流量。返回 429 时可以提供 Retry-After,但要避免客户端同时重试制造第二波峰值。

当下游处理不过来,上游必须收到“慢一点”的信号,而不是无限堆积队列。实现方式包括:

  • 限制队列长度。
  • 限制并发工作数量。
  • 当等待时间超阈值时拒绝新任务。
  • 暂停读取上游流,直到消费者追上。
  • 根据资源指标自动降级。

如果系统只会“接收并排队”,队列最终会耗尽内存;如果只会“失败”,用户体验会急剧下降。合理策略是按业务优先级保留关键请求,尽早拒绝低价值或可重试请求。

重试适合暂时性故障,不适合参数错误或不可恢复的冲突。重试必须:

  • 有最大次数和截止时间。
  • 使用指数退避和随机抖动,避免重试风暴。
  • 只对幂等操作或带幂等键的操作执行。
  • 区分网络超时、限流、服务不可用和业务失败。

熔断器在依赖连续失败后暂时停止请求,给上游恢复时间,也避免本地线程和连接被耗尽。它通常有闭合、打开、半开三个状态。

舱壁隔离把不同租户、任务或依赖放入独立资源池,避免一个慢依赖拖垮全部服务。例如为导出任务单独设置工作队列和并发上限。

5. 消息队列与任务队列

跳转到“5. 消息队列与任务队列”

队列把“立即完成”改成“稍后处理”,常见用途是发送邮件、生成报表、处理上传、同步数据和削峰。

API → 队列 → 工作者 → 数据库/外部服务
↘ 失败重试 → 死信队列
  • 至多一次:可能丢消息,但不会重复。
  • 至少一次:不丢,但可能重复,消费者必须幂等。
  • 恰好一次:通常是业务与消息系统共同实现的幂等效果,不是一句配置就能保证。

大多数系统选择至少一次投递,把重复处理交给业务幂等键解决。

  • 消息 ID 用于去重和追踪。
  • 处理时间有上限,超时任务可取消或转移到重试队列。
  • 失败有重试上限、退避和死信处理。
  • 需要顺序时明确分区键,不要假设全局顺序。
  • 监控积压量、消费速率、失败率和最老消息年龄。
  • 发布新消费者版本时考虑旧消息格式。

消息队列不能替代数据库事务。数据库提交成功但消息发送失败,或消息已发送但数据库回滚,都会造成不一致。常见解决方式是事务内写入 outbox 表,再由独立进程可靠投递。

应用并不直接“无限并发访问数据库”。连接池提供有限连接,过多连接会增加数据库切换和锁竞争,过少则请求排队。

需要观察:

  • 活跃连接、空闲连接、等待连接数。
  • 查询耗时、锁等待和事务持续时长。
  • 连接泄漏:借出后未归还。
  • 慢查询是否占满连接池。

扩展数据库的常见方向:

方式 作用 代价
索引与查询优化 减少单次查询成本 需要维护,写入也会变慢
读副本 分担读流量 复制延迟与一致性
缓存 减少重复查询 失效与陈旧数据
分库分表 扩大容量与并发 路由、跨分片查询、事务复杂
异步化 把非关键操作移出请求 最终一致与重试

优先优化查询和索引,再考虑增加基础设施。分片会把一个数据库问题变成分布式系统问题。

数据库适合结构化记录和事务,不等于适合所有访问模式。

  • 全文搜索通常使用倒排索引和专用搜索引擎,处理分词、相关性排序和聚合。
  • 大文件应放在对象存储,数据库保存元数据、权限和引用。
  • 图片、视频、音频需要格式校验、尺寸/时长限制、转码和访问控制。
  • 搜索索引与主数据库之间通常是异步同步,必须容忍延迟和重建。

不要把大二进制直接塞进主库,也不要把需要事务的记录只放进搜索索引。不同存储承担不同事实来源,边界要写清楚。

系统在高负载下应该“有序退化”,而不是全面崩溃。可以按优先级设计:

  1. 保留登录、支付、核心读取等关键路径。
  2. 降低非关键刷新和统计频率。
  3. 暂时关闭高成本导出、推荐或批量任务。
  4. 对可延迟任务返回“稍后重试”。
  5. 在入口拒绝明显超额的请求。

降级必须是可见的:客户端知道结果不完整,监控记录降级状态,恢复后有明确条件。

  • 是否知道当前 QPS、并发数、p95/p99 延迟和错误率。
  • 并发模型是否与任务性质匹配,是否有阻塞事件循环的调用。
  • 超时是否分层设置,取消是否能传到下游。
  • 缓存键是否包含权限与租户维度,失效策略是否明确。
  • 是否防护穿透、击穿、雪崩和热点倾斜。
  • 限流是否有合理维度,返回的等待时间是否可执行。
  • 队列是否幂等、可重试、可追踪,是否有死信处理。
  • 连接池、线程池和队列是否都有上限。
  • 搜索和文件是否放在合适存储,事实来源是否唯一。
  • 过载时是否按业务优先级降级,而不是一起失败。