并发、缓存与异步
系统变慢通常不是因为“代码不够快”这一句话,而是因为某处正在等待:等 CPU、等内存、等锁、等磁盘、等数据库、等网络,或者等前面排队的任务。并发、缓存、队列和限流都在处理这些等待,只是位置和代价不同。
1. 延迟、吞吐与排队
跳转到“1. 延迟、吞吐与排队”- 延迟(latency):一次操作从发出到完成用了多久。
- 吞吐(throughput):单位时间能完成多少个操作。
- 并发度(concurrency):同一时刻有多少操作处于进行中。
- 并行度(parallelism):同一时刻有多少操作真的同时执行。
异步请求不一定并行执行。一个 CPU 核心可以在多个请求之间切换;如果一个请求执行长循环,其他请求仍会等待。
系统负载升高时,请求不会线性变慢,而会先进入队列。队列变长后,等待时间迅速增加,最终超时、重试又制造更多请求,形成正反馈故障。
可以用一个简化关系估算并发需求:
Little 定律:并发数 ≈ 到达速率 × 平均驻留时间例如每秒收到 200 个请求,每个请求平均处理 0.5 秒,则平均约有 100 个请求同时在系统内。峰值还要考虑突发、长尾和依赖变慢。
平均值经常掩盖问题。一个接口平均 100 ms,但 1% 请求要 5 秒,用户仍会明显感到卡顿。观察 p50、p95、p99 和最大值,并明确统计窗口与样本量。
2. 并发模型
跳转到“2. 并发模型”2.1 进程、线程与协程
跳转到“2.1 进程、线程与协程”- 多进程:隔离强,崩溃影响较小,但进程间通信和内存成本更高。
- 多线程:共享地址空间,适合 CPU 并行,但共享状态需要锁和其他同步。
- 事件循环/协程:少量线程管理大量 I/O 等待,适合高并发网络服务,但阻塞调用会拖住整个事件循环。
一种模型没有绝对优势。CPU 密集任务需要真正的并行计算;大量慢网络请求更依赖非阻塞 I/O;隔离要求高的任务可能适合独立进程或容器。
2.2 竞态条件
跳转到“2.2 竞态条件”竞态发生在结果依赖不可控的执行顺序:
线程 A:读取余额 100线程 B:读取余额 100线程 A:写入 100 - 30 = 70线程 B:写入 100 - 50 = 50最终只扣了一笔。锁、原子操作、数据库条件更新或串行化队列可以解决不同场景的问题,但都要明确锁的范围和失败时的状态。
加锁过多会降低并发,并可能导致死锁。死锁需要同时满足互斥、持有并等待、不可抢占和循环等待等条件;预防方式包括固定锁顺序、设置等待超时、减少锁粒度,或改用无共享设计。
2.3 取消与超时
跳转到“2.3 取消与超时”一个请求超时后,后台工作不一定自动停止。如果客户端已经放弃,服务端仍在查询数据库、调用第三方或占用线程,资源会继续消耗。
取消信号应从入口沿调用链传播,并给每一步设置合理的超时。超时值不能拍脑袋:要基于依赖延迟分布和业务可接受等待,而不是把所有层都设成 30 秒。
3. 缓存
跳转到“3. 缓存”缓存用“可能旧一点的数据”换更低的延迟、更少的数据库压力和更高的吞吐。引入缓存前先回答四个问题:
- 缓存什么数据,键是什么?
- 数据允许旧多久?
- 数据变化时,谁负责让缓存失效?
- 缓存不可用或未命中时,系统怎样降级?
3.1 缓存层
跳转到“3.1 缓存层”| 层 | 典型对象 | 特点 |
|---|---|---|
| 浏览器 | 静态资源、API 响应 | 离用户最近,受 Cookie、CORS 和缓存头影响 |
| CDN | 图片、脚本、公开内容 | 边缘节点缓存,需处理回源和刷新 |
| 应用进程内 | 配置、热点对象 | 最快,但多实例之间可能不一致 |
| 分布式缓存 | 会话、查询结果 | 多实例共享,增加网络和一致性成本 |
| 数据库缓存 | 页、查询计划 | 数据库内部管理,应用通常不可直接控制 |
3.2 常见模式
跳转到“3.2 常见模式”- Cache-Aside:应用先查缓存,未命中再查数据库并回填。
- Read-Through:缓存层自己负责未命中时加载。
- Write-Through:更新数据时同时更新缓存。
- Write-Behind:先更新缓存,异步写回数据库,可能丢数据。
Cache-Aside 最常见,但“先改数据库还是先删缓存”存在短暂不一致。删除缓存通常比直接更新更稳妥;还要处理并发读写导致旧值重新回填的问题,例如延迟双删、版本号或订阅变更事件。
3.3 缓存故障模式
跳转到“3.3 缓存故障模式”| 问题 | 现象 | 防护 |
|---|---|---|
| 缓存穿透 | 查询不存在的键,每次都打数据库 | 空值缓存、参数校验、布隆过滤器 |
| 缓存击穿 | 热点键过期,大量请求同时回源 | 互斥重建、逻辑过期、预热 |
| 缓存雪崩 | 大量键同时过期或缓存整体不可用 | 随机 TTL、分层缓存、降级与限流 |
| 热点倾斜 | 单个键压垮一个节点 | 分片热点、请求合并、限流 |
| 陈旧数据 | 写入后仍读到旧值 | 版本、失效、短 TTL、业务容忍度 |
3.4 键和一致性
跳转到“3.4 键和一致性”缓存键必须包含区分结果的输入,例如租户、语言、权限范围、查询参数和版本。遗漏维度会把一个用户的数据错误地给另一个用户,这是安全问题,不只是缓存问题。
缓存不是数据库。需要强一致、可恢复的关键状态应持久化;缓存可以丢失。缓存命中率也不是越高越好,错误的命中可能让用户长期看到旧数据。
4. 限流、背压与过载保护
跳转到“4. 限流、背压与过载保护”4.1 限流
跳转到“4.1 限流”限流限制单位时间的请求数或资源消耗,保护自己和上游。
常见算法:
- 固定窗口:每分钟一个计数,简单,但窗口边界可能瞬间放过两倍流量。
- 滑动窗口:更平滑地统计一段时间,状态和计算成本更高。
- 令牌桶:按速率补充令牌,允许一定突发。
- 漏桶:按固定速率处理,平滑输出,但可能增加等待。
限流维度可以是 IP、用户、租户、API Key、接口或全局。只按 IP 可能误伤共享出口,只按用户又挡不住匿名流量。返回 429 时可以提供 Retry-After,但要避免客户端同时重试制造第二波峰值。
4.2 背压
跳转到“4.2 背压”当下游处理不过来,上游必须收到“慢一点”的信号,而不是无限堆积队列。实现方式包括:
- 限制队列长度。
- 限制并发工作数量。
- 当等待时间超阈值时拒绝新任务。
- 暂停读取上游流,直到消费者追上。
- 根据资源指标自动降级。
如果系统只会“接收并排队”,队列最终会耗尽内存;如果只会“失败”,用户体验会急剧下降。合理策略是按业务优先级保留关键请求,尽早拒绝低价值或可重试请求。
4.3 重试、熔断与隔离
跳转到“4.3 重试、熔断与隔离”重试适合暂时性故障,不适合参数错误或不可恢复的冲突。重试必须:
- 有最大次数和截止时间。
- 使用指数退避和随机抖动,避免重试风暴。
- 只对幂等操作或带幂等键的操作执行。
- 区分网络超时、限流、服务不可用和业务失败。
熔断器在依赖连续失败后暂时停止请求,给上游恢复时间,也避免本地线程和连接被耗尽。它通常有闭合、打开、半开三个状态。
舱壁隔离把不同租户、任务或依赖放入独立资源池,避免一个慢依赖拖垮全部服务。例如为导出任务单独设置工作队列和并发上限。
5. 消息队列与任务队列
跳转到“5. 消息队列与任务队列”队列把“立即完成”改成“稍后处理”,常见用途是发送邮件、生成报表、处理上传、同步数据和削峰。
API → 队列 → 工作者 → 数据库/外部服务 ↘ 失败重试 → 死信队列5.1 投递语义
跳转到“5.1 投递语义”- 至多一次:可能丢消息,但不会重复。
- 至少一次:不丢,但可能重复,消费者必须幂等。
- 恰好一次:通常是业务与消息系统共同实现的幂等效果,不是一句配置就能保证。
大多数系统选择至少一次投递,把重复处理交给业务幂等键解决。
5.2 消费者设计
跳转到“5.2 消费者设计”- 消息 ID 用于去重和追踪。
- 处理时间有上限,超时任务可取消或转移到重试队列。
- 失败有重试上限、退避和死信处理。
- 需要顺序时明确分区键,不要假设全局顺序。
- 监控积压量、消费速率、失败率和最老消息年龄。
- 发布新消费者版本时考虑旧消息格式。
消息队列不能替代数据库事务。数据库提交成功但消息发送失败,或消息已发送但数据库回滚,都会造成不一致。常见解决方式是事务内写入 outbox 表,再由独立进程可靠投递。
6. 数据库连接与扩展
跳转到“6. 数据库连接与扩展”应用并不直接“无限并发访问数据库”。连接池提供有限连接,过多连接会增加数据库切换和锁竞争,过少则请求排队。
需要观察:
- 活跃连接、空闲连接、等待连接数。
- 查询耗时、锁等待和事务持续时长。
- 连接泄漏:借出后未归还。
- 慢查询是否占满连接池。
扩展数据库的常见方向:
| 方式 | 作用 | 代价 |
|---|---|---|
| 索引与查询优化 | 减少单次查询成本 | 需要维护,写入也会变慢 |
| 读副本 | 分担读流量 | 复制延迟与一致性 |
| 缓存 | 减少重复查询 | 失效与陈旧数据 |
| 分库分表 | 扩大容量与并发 | 路由、跨分片查询、事务复杂 |
| 异步化 | 把非关键操作移出请求 | 最终一致与重试 |
优先优化查询和索引,再考虑增加基础设施。分片会把一个数据库问题变成分布式系统问题。
7. 搜索与文件
跳转到“7. 搜索与文件”数据库适合结构化记录和事务,不等于适合所有访问模式。
- 全文搜索通常使用倒排索引和专用搜索引擎,处理分词、相关性排序和聚合。
- 大文件应放在对象存储,数据库保存元数据、权限和引用。
- 图片、视频、音频需要格式校验、尺寸/时长限制、转码和访问控制。
- 搜索索引与主数据库之间通常是异步同步,必须容忍延迟和重建。
不要把大二进制直接塞进主库,也不要把需要事务的记录只放进搜索索引。不同存储承担不同事实来源,边界要写清楚。
8. 过载时的系统行为
跳转到“8. 过载时的系统行为”系统在高负载下应该“有序退化”,而不是全面崩溃。可以按优先级设计:
- 保留登录、支付、核心读取等关键路径。
- 降低非关键刷新和统计频率。
- 暂时关闭高成本导出、推荐或批量任务。
- 对可延迟任务返回“稍后重试”。
- 在入口拒绝明显超额的请求。
降级必须是可见的:客户端知道结果不完整,监控记录降级状态,恢复后有明确条件。
检查清单
跳转到“检查清单”- 是否知道当前 QPS、并发数、p95/p99 延迟和错误率。
- 并发模型是否与任务性质匹配,是否有阻塞事件循环的调用。
- 超时是否分层设置,取消是否能传到下游。
- 缓存键是否包含权限与租户维度,失效策略是否明确。
- 是否防护穿透、击穿、雪崩和热点倾斜。
- 限流是否有合理维度,返回的等待时间是否可执行。
- 队列是否幂等、可重试、可追踪,是否有死信处理。
- 连接池、线程池和队列是否都有上限。
- 搜索和文件是否放在合适存储,事实来源是否唯一。
- 过载时是否按业务优先级降级,而不是一起失败。