高并发系统
高并发架构设计要点
衡量指标
- 高性能:代表系统并行处理能力,关乎硬件资源节约和用户体验。
- 平均响应时间: 易受极端值影响,不推荐作为单一指标。
- 响应时间PCTn统计方式: PCTn表示请求响应时间按从小到大排序后的第n分位响应时间。
- PCT50=1ms: 50%请求在1ms内响应。
- PCT99=800ms: 99%请求在800ms内响应。
- PCT999=1.2s: 99.9%请求在1.2s内响应。
- 经验推荐: 平均响应时间 < 200ms,且 PCT99 < 1s。
- 高可用性指标:系统长期稳定、正常对外提供服务,避免故障、宕机、崩溃。
- 可用性 = 系统正常运行时间 / 系统总运行时间。
- 使用“N个9”描述,例如:
- 99% (2个9): 一年内故障3.65天,一日内故障14.4分钟。
- 99.9% (3个9): 一年内故障8小时,一日内故障1.44分钟。
- 要求: 至少3个9或4个9,实际监控常取99.95%。
- 可扩展性指标:系统能通过水平扩容应对请求量增长和突发流量。
- 可扩展性 = 吞吐量提升比例 / 集群节点增加比例。
- 经验推荐: 70% ~ 80% 可扩展性。
高并发场景分类
- 高并发请求可归类为高并发读和高并发写,因为两种场景通常有不同的解决方案。
数据库读/写分离
适用于读多写少的应用,将数据库请求分为读和写,分别由不同的数据库处理。
读/写分离架构
- 通常使用数据库主从复制技术实现,Master作为写库,Slave作为读库,一个Master可连接多个Slave。
读/写请求路由方式
- 基于数据库Proxy代理的方式:
- 在业务服务和数据库服务器之间增加Proxy代理节点,Proxy根据SQL语句(如insert/delete/update转Master,select转Slave)进行路由。
- 开源项目:MySQL-Proxy, MyCat (中心化代理), MySQL-Router (本地代理)。
- 基于应用内嵌的方式:
- 在业务服务进程内进行请求读/写分离。
- 开源项目:gorm, shardingjdbc。
主从延迟与解决方案
- 问题: 数据库主从复制存在数据复制延迟(主从延迟),导致Slave数据不一致。
- 解决方案:
- 同步数据复制: Master等待所有Slave收到数据后才返回成功。
- 优点:保证数据强一致性。
- 缺点:写请求延迟大大增加,数据库吞吐量下降,仅适用于低并发场景。
- 强制读主: 对于对主从延迟容忍性低的业务场景,强制将读请求路由到Master。
- 会话分离: 某会话执行写操作后,在极短时间内其读请求强制路由到Master,保证“读己之所写”。
- 同步数据复制: Master等待所有Slave收到数据后才返回成功。
本地缓存
业务服务进程将获取到的数据缓存到本地内存中,将网络请求转化为高效内存存取,提高数据读取效率。
基本的缓存淘汰策略
- FIFO (First In First Out): 优先淘汰最早进入缓存的数据。命中率低,很少使用。
- LFU (Least Frequently Used): 优先淘汰最不常用的数据。通过访问计数实现,适合热点数据,但早期高频数据可能长期占用。
- LRU (Least Recent Used): 优先淘汰最近最少使用的数据。基于双向链表和哈希表实现,能提高短期热点数据的命中率,但偶发性访问冷数据或批量访问可能导致热点数据被淘汰。
- W-TinyLFU: 结合LFU和LRU优点的更复杂策略。
W-TinyLFU 策略
- 结合了LFU和LRU的优点,兼具高缓存命中率和低内存占用,被Redis和Caffeine Cache使用。
- 内存空间布局:
- Window LRU段 (1%总缓存空间): 使用LRU策略缓存数据。
- Segment LRU段 (SLRU): 划分为
protected段 (80%) 和probation段 (20%)。probation段存储最近被访问1次的缓存数据。protected段存储最近被访问至少2次的缓存数据。
- 工作流程:
- 首次访问数据X缓存到Window LRU。
- Window LRU满时,LRU淘汰数据移入probation段。
- 数据X再次被访问时,移入protected段。
- protected段满时,LRU淘汰数据X移入probation段。
- probation段满时,淘汰数据V,与数据X比较访问频率,高者留下。
- Count-Min Sketch近似算法: 用于保存每条缓存数据的访问频率。
- 原理: 选定M个哈希函数,分配一个N行M列的二维数组。数据访问时,M个哈希值取模,对应M个位置值加1。查询时取M个位置中的最小值。
- 优化: 每个位置4bit表示,上限15次。采用基于滑动窗口的时间衰减设计机制,定时将访问频率除以2,解决大量数据均达到15次频率的问题。
缓存击穿与SingleFlight
- 缓存击穿: 缓存中一条热门数据在缓存失效的瞬间,大量并发请求直接访问数据库,导致数据库被击垮。
- SingleFlight: Golang语言扩展包提供的同步原语,用于解决缓存击穿问题。
- 原理: 将对同一条数据的并发请求进行合并,只允许一个请求访问数据库,并将获取到的数据结果与其他请求共享。
- 核心机制: 使用
sync.WaitGroup。第一个请求调用wg.Add(1)并执行数据访问;其他并发请求调用wg.Wait()阻塞等待。数据访问完成后,调用wg.Done()将WaitGroup计数归零,唤醒其他请求,共享结果。
分布式缓存
针对本地缓存无法共享、编程语言限制、可扩展性差、内存易失性等问题,引入分布式缓存。
分布式缓存选型
- Memcached vs Redis: Redis更流行。
- 数据类型丰富: Redis支持字符串、列表、集合、哈希、有序集合等。Memcached只支持字符串。
- 数据可持久化: Redis支持RDB和AOF机制。Memcached无。
- 高可用性: Redis支持主从复制,故障后可通过主从切换保证服务不间断。
- 分布式能力: Memcached需客户端实现一致性哈希。Redis有官方的Redis Cluster,业界也有Codis、Twemproxy等方案。
- 结论: Redis因数据类型丰富、持久化、高可用、高扩展性成为首选。
如何使用Redis缓存
- 尝试从Redis缓存查找数据。命中则返回。
- 未命中则从数据库读取数据。
- 将数据保存到Redis缓存,并设置过期时间。
- 下次请求命中缓存。
- 设置过期时间的好处:
- 避免数据堆积造成资源浪费,自动删除不再访问的数据。
- 当数据库与Redis缓存出现数据不一致时,过期时间作为兜底手段,保证最终一致性。
缓存穿透
- 问题: 用户请求访问一条数据库中不存在的非法数据,Redis缓存形同虚设,请求直接穿透到数据库,可能导致数据库崩溃。
- 初步方案: 在Redis缓存中保存空值,拦截后续请求。
- 缺点: 大量空值可能占据内存,降低命中率。
- 优化方案: 使用布隆过滤器 (Bloom Filter)。
- 原理: 由固定长度m的二进制向量和K个哈希函数组成。数据加入时,K个哈希值取模,对应位设为1。查询时,若K个位置有任意一个为0,则数据一定不存在;若都为1,则数据可能存在(存在误判率)。
- 应用: 将数据库所有数据加入布隆过滤器。请求未命中Redis时,先查布隆过滤器。若认为不存在,则不再访问数据库。若认为可能存在,则继续访问数据库。即使有误判,也能大大减少对数据库的无效访问,且空值缓存量极少。
缓存雪崩
- 问题: Redis缓存中大量数据在同一时间过期,或Redis服务宕机,导致请求全部涌向数据库。
- 解决方案:
- 过期时间随机分布: 避免大量数据有相同过期时间。
- 高可用Redis集群: 降低Redis服务宕机概率。
缓存更新
- 讨论四种缓存与数据库数据更新策略,目标是保证数据一致性。
- 方案1: 先修改缓存,再更新数据库
- 问题: 并发写请求可能导致缓存与数据库数据不一致(如A先改缓存,B后改缓存,B先更新DB,A后更新DB,最终缓存是B的值,DB是A的值)。不支持事务回滚,不可取。
- 方案2: 先更新数据库,再修改缓存
- 问题: 并发写请求可能导致缓存与数据库数据不一致(与方案1类似,只是顺序相反)。不可取。
- 方案3: 先删除缓存,再更新数据库
- 问题: 并发读/写请求可能导致数据不一致。
- 写请求A删除缓存,读请求B未命中,B从DB读取旧数据a并写入缓存,A更新DB为b。此时缓存为a,DB为b。
- 问题: 并发读/写请求可能导致数据不一致。
- 方案4: 先更新数据库,再删除缓存 (推荐)
- 优点: 很好地解决了并发写和并发读/写场景下的数据不一致问题。最后一个操作总是删除缓存,保证后续读请求从数据库获取最新值。
- 删除缓存失败处理:
- 简单重试删除缓存。
- 使用消息队列监听数据库binlog,异步删除缓存,利用消息队列重试机制。
- 推荐: 失败时仅执行一次异步重试,如果仍失败则不再处理,因为概率极低,且缓存过期时间机制可作为兜底。
高并发读场景总结:CQRS
CQRS (Command Query Responsibility Segregation, 命令查询职责分离) 模式将数据的读取操作与更新操作分离,其本质是读/写分离。
CQRS的简要架构与实现
- 客户端发起
command(写请求) 交给写数据存储处理。 - 写数据存储完成数据变更后,将数据变更消息发送到消息队列。
- 读数据存储监听消息队列,收到数据变更消息后将数据写入自身。
- 客户端发起
query(读请求) 交给读数据存储处理。 - 读数据存储返回数据。
- 组件:
- 写数据存储: 适合高并发写入的存储系统。
- 读数据存储: 适合高并发读取的存储系统。
- 数据传输通道: 足够健壮,保证数据不丢失(如消息队列、定时任务)。
- 案例:
- 数据库读/写分离: 写数据存储是Master,读数据存储是Slave,消息队列是数据库主从复制。
- 分布式缓存: 写数据存储是数据库,读数据存储是Redis缓存,消息队列是消息中间件监听数据库binlog。
更多的使用场景
- 搜索场景:
- 数据库 (写数据存储) 负责账号信息管理。
- Elasticsearch (读数据存储) 负责搜索用户昵称。
- 消息中间件监听数据库binlog,将昵称更改同步到Elasticsearch。
- 多表关联查询场景:
- 将复杂的业务数据进行聚合计算,结果存储到包含全部关联字段的宽表中 (读数据存储)。
- 查询时直接读取宽表,避免昂贵的SQL join。
- Worker服务使用消息中间件监听数据库binlog或定时任务,执行数据聚合计算并写入宽表。
CQRS架构的特点
- 写数据存储和读数据存储通常选用不同的存储模型和存储选型,以优化各自的读写性能。
- 读数据有延迟,写数据存储和读数据存储仅保证最终一致性。
数据分片之数据库分库分表
数据分片是将待处理的数据或请求分成多份并行处理,是应对高并发写请求的常用方案。
分库和分表
- 分库: 将数据库拆分为多个小数据库,分散存储数据。解决并发量大问题。
- 分表: 将单个数据表拆分为多个结构完全一致的表,分散存储数据。解决数据量大问题 (如单表超2000万行)。
- 拆分维度: 垂直拆分 (基于业务), 水平拆分 (基于数据)。
垂直拆分
- 垂直分库:
- 原则: 按照业务归属将数据表分类,不同业务表拆分到不同数据库。核心是“专库专用”。
- 优点: 实现不同业务数据解耦,团队职责单一;不同业务使用不同服务器,提高数据库并发处理能力。
- 垂直分表:
- 原则: 将一个数据表按照字段分成多个表,每个表存储一部分字段(如按访问频率、字段值大小)。
- 优点: 隔离核心数据和非核心数据,提高查询命中率,减少磁盘I/O。
- 限制: 仅适合数据量不大但字段多的场景,未消除单表数据量过大问题。
水平拆分
- 水平分库:
- 原则: 将同一个数据库中的数据按照某种规则拆分到多个数据库,这些数据库可部署在不同服务器上,且结构一致。
- 优点: 充分利用多服务器资源,提高数据库并发处理能力;有效控制每个数据库内单表数据量。
- 水平分表:
- 原则: 在同一个数据库内,将一个数据表中的数据按照某种规则拆分到多个表,且结构一致。
- 优点: 解决了单表数据量过大的问题。
- 限制: 拆分后的表仍在同一数据库,竞争服务器资源。
- 水平分库分表: 结合水平分库和水平分表,达到分布式效果。
- 选择依据:
- 高并发但数据量小 -> 只选择水平分库。
- 低并发但数据量大 -> 只选择水平分表。
- 高并发且数据量大 -> 选择水平分库分表。
水平拆分规则 (数据路由算法)
- 目标: 保证每个分区数据量和读/写请求量均等,避免数据偏斜和数据热点。
- 范围分区法:
- 依据: 可排序字段值区间 (如ID、创建时间)。
- 优点: 方便分区查询,扩容友好。
- 缺点: 易受分区字段属性影响导致数据偏斜 (如用户昵称随机性),或数据热点 (如时间属性)。
- 哈希分区法:
- 依据: 数据字段哈希值与数据分区数目N取模。
- 优点: 实现简单,无视数据字段属性,能使数据量均匀分布,避免数据偏斜和热点。
- 缺点: 扩容不灵活,N变化需重新分区。
- 改进: 与范围分区法结合,对哈希值进行范围分区。
- 一致性哈希分区法:
- 核心: 哈希环。数据分区和数据键哈希后映射到环上,数据顺时针查找第一个分区存储。
- 优点: 增减数据分区时,只有逆时针相邻数据需重新分区,影响范围小。
- 缺点: 数据分区少时,分布可能集中,造成数据偏斜。
- 解决方案: 虚拟节点机制。为每个数据分区计算多个哈希值(虚拟节点),使其在哈希环上分布更均匀,减少数据偏斜。
扩容方案
- 从库升级法 (翻倍扩容法):
- 为需扩容的分库增加Slave节点,进行主从复制。
- 主从复制完成后,临时封禁Master写请求,检查数据一致性后断开主从。
- 修改原Master的数据范围为原数据范围的前一半。
- 将原Slave提升为Master,并设置数据范围为原数据范围的后一半。
- 确认业务感知变更后,解封写请求。
- 启动离线任务,删除冗余数据。
- 对整个数据库扩容时,每个分库都执行上述从库升级操作,分库数量翻倍。
其他数据分片形式
- Kafka 多 Partition: Topic内部拆分为多个Partition,并行写入消息,提高吞吐量。
- 秒杀系统分布式锁: 将产品库存拆分为N份,每份库存使用单独的分布式锁保护,秒杀请求根据用户ID取模竞争对应库存和锁,实现并行加锁。
- ConcurrentHashMap: 内部数据拆分到多个槽,为每个槽独立加锁,支持并发读/写,减少线程竞争。
异步写与写聚合
通过业务和数据特点来应对高并发写场景,作为数据分片的补充。
异步写
- 概念: 将写请求交互流程从“同步等待结果”转变为“提交请求后异步查询结果”的两阶段交互。
- 特点:
- 将用户写请求快速暂存到数据池,立即响应用户提交成功。
- 后台任务从数据池读取请求并真正执行写操作。
- 写操作结果依靠用户主动查询,或完成后主动通知。
- 适用场景: 写请求量大但被请求方系统吞吐量跟不上,写请求先排队。
- 应用案例:
- 跨公网调用: 电商支付接入第三方支付平台。使用消息中间件对支付请求排队,消息消费者逐个跨公网调用支付接口,避免阻塞。
- 秒杀系统异步化: 用户抢购请求提交到消息中间件,立即响应“抢购中”;消息消费者按数据库处理能力消费请求,扣减库存和写入订单;结果保存Redis,用户刷新订单页查询。
写聚合
- 概念: 将若干写请求聚合成一个写请求,减少写请求量。
- 应用案例:
- Kafka Producer 批量生产: RecordAccumulator将相同Topic、相同Partition的消息聚合为Micro-Batch,一次性发送到Kafka集群,提高吞吐量。
- AliSQL热点数据优化: 对同一行的多个更新操作聚合为一个批次更新操作,消除行锁竞争,提高热点数据更新效率。