0%

高并发系统实战笔记

第一章 概述

1. 概述

互联网服务的价值在于高流量,高流量带来平台的成长和可能性,而高流量就带来了高并发

1.1. 成长路径

img

CRUD Boy:代码可复用性,业务逻辑组合

进阶选手:开源资料,技术图书

黄金成长期:主持高并发改造,活跃于技术社区等

实力战将…..

我应该是出于1和2这两个阶段中,实际工作是做CRUD,然后学习是通过开源资料。下一步要走入3,就需要在开源社区或者公司中接触高并发系统,进行锻炼

1.2. 如何实践高并发

步骤: 识别系统类型、完善监控系统、梳理改造要点、小步改造验证。

1.2.1. 识别系统类型

img

读多写少: 聚焦于如何通过缓存分担数据库查询压力,所以我们的学习重点就是做好缓存,包括但不限于数据梳理、做数据缓存、加缓存后保证数据一致性等等工作。

强一致性:承接高并发流量的同时,还要做好系统隔离性、事务一致性以及库存高并发争抢不超卖

写多读少: 写高并发的服务通常需要借助一些开源才能实现

读多写多: 这类系统数据基本都是在内存中直接对外服务,同时服务都要拆成很小的单元,数据是周期落到磁盘或数据库,而不是实时更新到数据库。因此我们的学习重点是如何用内存数据做业务服务、系统无需重启热更新、脚本引擎集成、脚本与服务互动交换数据、直播场景高并发优化、一些关于网络优化CDN和DNS、知识以及业务流量调度、客户端本地缓存等相关知识。

第二章 读多写少的系统

1. 结构梳理

读多写少 —> 整理数据,以便于缓存

1.1. 精简字段

长度小的数据在吞吐、查询、传输上都会很快,也会更好管理和缓存。

表字段如果缺少冗余会导致业务实现更为繁琐

“更多的字段”和“更少的职能”之间找到平衡

1.2. 判断不同种类的表是否适合缓存

实体数据表 通常适合长期缓存

实体关系表 实体辅助表 比较灵活,可能会判断是否需要缓存

动作历史表 通常不适合缓存

原则

  • 缓存是hash索引,可以提升用ID直接查询的数据的效率, 根据ID能够精准匹配的数据实体很适合做缓存;而通过String、List或Set指令形成的有多条value的结构适合做(1:1、1:n、m:n)辅助或关系查询;最后还有一点要注意,虽然Hash结构很适合做实体表的属性和状态,但是Hgetall指令性能并不好,很容易让缓存卡顿,建议不要这样做。

img

  • 范围查询、条件查询、统计计算的需要提前算好,定期更新,属于临时缓存
  • 数据增长量大或者跟设计初衷不一样的表数据,这种不适合、也不建议去做做缓存。

2. 缓存一致性

img

2.1. 临时热点缓存

2000万用户数据,都放到缓存中,不现实

用临时缓存,放空值,防止缓存穿透

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 尝试从缓存中直接获取用户信息
userinfo, err := Redis.Get("user_info_9527")
if err != nil {
return nil, err
}

//缓存命中找到,直接返回用户信息
if userinfo != nil {
return userinfo, nil
}

//没有命中缓存,从数据库中获取
userinfo, err := userInfoModel.GetUserInfoById(9527)
if err != nil {
return nil, err
}

//查找到用户信息
if userinfo != nil {
//将用户信息缓存,并设置TTL超时时间让其60秒后失效
Redis.Set("user_info_9527", userinfo, 60)
return userinfo, nil
}

// 没有找到,放一个空数据进去,短期内不再问数据库
// 可选,这个是用来预防缓存穿透查询攻击的
Redis.Set("user_info_9527", "", 30)
return nil, nil

2.2. 缓存更新

2.2.1. 单个实体数据更新

先更后删,也可以用队列通知更新

2.2.2. 关系型或者统计型数据更新

例子:缓存中有user_friend_list_cache_XXXX 表示XXXX的好友信息,好友信息中存着好友的实体信息,当我们在修改一个用户的昵称时,需要删除包含它的缓存。img

  • 人工写逻辑 服务代码中写,耦合性强
  • 订阅变更后由消息队列处理可以实现解耦
  • 如果数据更新少,可以给表设置全局版本号,或者是划分范围+局部版本号
  • 如果涉及到的关联的key包含更新的实体的ID的话,可以方便的删除
  • 异步脚本遍历数据库刷新所有相关缓存

2.3. Token 减低身份鉴权的流量压力

这个例子主要是学习不用缓存查询,而直接从请求中拿到想要的信息

session方式:所有的请求都需要查询session cache进行鉴权,有性能瓶颈,且容易单点故障

token方式:在请求中携带token,验证token合法性即可完成鉴权,避免session的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//header
//加密头
{
"alg": "HS256", // 加密算法,注意检测个别攻击会在这里设置为none绕过签名
"typ": "JWT" //协议类型
}

//PAYLOAD
//负载部分,存在JWT标准字段及我们自定义的数据字段
{
"userid": "9527", //我们放的一些明文信息,如果涉及敏感信息,建议再次加密
"nickname": "Rick.Xu", // 我们放的一些明文信息,如果涉及隐私,建议再次加密
"iss": "geekbang",
"iat": 1516239022, //token发放时间
"exp": 1516246222, //token过期时间
}

//签名
//签名用于鉴定上两段内容是否被篡改,如果篡改那么签名会发生变化
//校验时会对不上

token的离线和更换

这个token内部包含过期时间,快过期的token会在客户端自动和服务端通讯更换,这种方式可以大幅提高截取客户端token并伪造用户身份的难度。

token过期需要到用户中心更换

需要两种token:一种是refresh_token,用于更换access_token,有效期是30天;另一种是access_token,用于保存当前用户信息和权限信息,每隔15分钟更换一次。如果请求用户中心失败,并且App处于离线状态,只要检测到本地refresh_token没有过期,系统仍可以继续工作,直到refresh_token过期为止,然后提示用户重新登陆。这样即使用户中心坏掉了,业务也能正常运转一段时间。

短token 降低token被盗风险

长token 降低用户中心崩溃带来的客户无法跟换token

情况 风险结果 风险窗口
短 Token 被盗 攻击者可以直接访问资源 仅在短 Token 的有效期内(如 15 分钟)
长 Token 被盗 攻击者需要去用户中心换取短 Token;如果用户中心有风控(IP 限制、单设备策略),可以拦截 风险较低,取决于用户中心安全性
长 Token + 短 Token 都被盗 攻击者立刻能用短 Token 访问资源,并在过期后用长 Token 继续刷新 风险窗口 = 长 Token 的有效期,或者直到用户中心吊销它
长 Token + 短 Token 都被盗 + 用户中心崩溃 攻击者手上有短 Token 可以继续访问,过期后由于用户中心挂掉,刷新过程也停了。风险窗口 = 用户中心恢复之前的时间 风险时间 = 短 Token 的剩余有效期 + 用户中心恢复时间

2.4. 同城双活

防止单机故障,引发的生产事故

难点:延迟,如何保证双活

主从同步(单次请求)延迟:

  • 同机房服务器:0.1 ms
  • 同城服务器(100公里以内) :1ms(10倍 同机房)
  • 北京到上海: 38ms(380倍 同机房)
  • 北京到广州:53ms(530倍 同机房)

otter工具

HttpDNS调度,让一个用户在某一段时间内只在一个机房内活跃,这样可以降低数据冲突的情况

2.5. 多机房一致性 Raft

img

第三章 强一致性的系统

强一致性 –> 要用到锁、分布式事务 —> 有较大的性能瓶颈 —-> 系统隔离

1. 领域拆分

随着排期不断调整和新排期的不断加入,订单数据就会持续增加,一年内订单数据量达到了一亿多条。因为数据过多、合作周期长,并且包含了售后环节,所以这些数据无法根据时间做归档,导致整个系统变得越来越慢。

考虑到这是核心业务,如果持续存在问题影响巨大,因此朋友找我取经,请教如何对数据进行分表拆分。但根据我的理解,这不是分表分库维护的问题,而是系统功能设计不合理导致了系统臃肿。于是经过沟通,我们决定对系统订单系统做一次领域拆分。

拆分理论

img

抽象服务的方法

被动拆分法、动态辅助表方式、标准抽象

对比点 三层架构(MVC) DDD
分层依据 技术维度(表现层、逻辑层、数据层) 业务维度(应用、领域、基础设施)
业务逻辑位置 Service 层(容易膨胀) 领域层(聚合/实体/领域服务)
实体模型 贫血模型(只有数据,没有行为) 充血模型(有数据+业务方法)
适用场景 业务简单,偏 CRUD,快速开发 业务复杂,领域知识密集,规则演化频繁
维护成本 初期低,后期复杂时 Service 容易失控 初期学习成本高,后期业务清晰可控
核心思想 分清技术层次 分清业务边界和领域规则

2. 强一致锁

几种加锁的方式

行锁、互斥锁:最差的锁,导致串行化

单个动作

原子操作、redis中放库存拆分key(数量少的时候导致多次判断)、redis维护令牌队列

多个动作

自旋互斥超时锁(redis set nx)、CAS乐观锁、lua脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//开启事务
redis> multi
OK

// watch 修改值
// 在exec期间如果出现其他线程修改,那么会自动失败回滚执行discard
redis> watch prod_1475_stock_queue prod_1475_stock_1

//事务内对数据进行操作
redis> rpop prod_1475_stock_queue 1
QUEUED

//操作步骤2
redis> decr prod_1475_stock_1
QUEUED

//执行之前所有操作步骤
//multi 期间 watch有数值有变化则会回滚
redis> exec
3

img

3. 系统隔离

3.1. 网关

作用

  • 外网网关是对外的“城门口守卫”,防坏人、管流量。
  • 内网网关是对内的“交通枢纽”,管车流、调度路线。

例子:

外网网关:Spring Cloud Gateway,处理所有外部请求。

  • /api/order/** → 转发到订单服务。
  • /api/user/** → 转发到用户服务。

内网网关:微服务之间通过 Feign + 内网网关 调用,

  • 订单服务 → 内网网关 → 用户服务。
  • 内网网关做负载均衡(调用不同实例)、熔断(Hystrix/Resilience4j)。

img

1️⃣ 外网请求访问

用户在浏览器下单:

1
用户浏览器 → 外网网关 → 外网订单服务 → 数据库
  • 外网网关:做 HTTPS、鉴权、限流、防爬虫
  • 外网订单服务:处理订单逻辑
  • 数据库:存储订单信息

2️⃣ 内网访问

假设内网风控系统要校验订单风险:

1
外网订单服务 → 内网网关 → 风控服务
  • 外网不能直接调用风控服务,只能通过 内网网关
  • 内网网关做安全控制、路由、熔断

3️⃣ 数据同步到内网(异步 Kafka)

  • 外网订单服务下单成功后,不直接写内网数据库,而是发送事件到 Kafka:
1
外网订单服务 → Kafka Topic(order_created) → 内网清算/风控/数据分析服务订阅
  • 内网服务根据事件更新自己的数据库
  • 异步机制避免高峰期直接打内网数据库,保证稳定性

4️⃣ 各系统独立

  • 外网服务、内网服务都在 独立集群
  • 每个服务有自己的数据库、网关
  • 即使外网流量很高,也不会直接压到内网服务

3.2. 网关隔离和随时熔断

熔断的核心目的是 保护整个系统的可用性,而不是停掉服务。

  • 阻止请求打垮下游
  • 提供降级方案保证部分功能可用
  • 给下游恢复时间

例子:

  • 拒绝请求调用库存服务

  • 快速返回降级结果,比如:

    • 返回默认库存值
    • 返回缓存库存
    • 返回友好提示:“库存服务暂不可用”

3.3. 减少内网API互动

为了防止共享的数据被多个系统同时修改,我们会在活动期间把参与活动的数据和库存做推送,然后自动锁定,这样做可以防止其他业务和后台对数据做修改。若要禁售,则可以通过后台直接调用前台业务接口来操作;活动期间也可以添加新的商品到外网业务中,但只能增不能减。

img

我们需要把活动交易结果同步回内网,而同步期间外网还是能继续交易的。如果不保持锁定,数据的流向不小心会成为双向同步,这种双向同步很容易出现混乱,系统要是因此出现问题就很难修复 。

img

3.4. 分布式队列控流和离线同步

  • 队列拥有良好吞吐并且能够动态扩容,可应对各种流量冲击场景;
  • 可通过动态控制内网消费线程数,从而实现内网流量可控;
  • 内网消费服务在高峰期可以暂时离线,内网服务可以临时做一些停机升级操作;
  • 内网服务如果出现bug,导致消费数据丢失,可以对队列消息进行回放实现重新消费;
  • Kafka是分区消息同步,消息是顺序的,很少会乱序,可以帮我们实现顺序同步;
  • 消息内容可以保存很久,加入TraceID后查找方便并且透明,利于排查各种问题。

4. 分布式事务

背景:DDD领域驱动是一种拆分微服务的方式, 服务被拆分得更细,并且都是独立部署,拥有独立的数据库,这就导致要想保持事务一致性实现就更难了,因此跨越多个服务实现分布式事务已成为刚需。

第四章 读多写少的系统

读多写多,第一反应是应该避免这种情况,如果实时性、一致性要求不高,可以有降级、限流、多级缓存等等操作,如果是在不行就只能分片扩容

用缓存的时候如果想避免网络开销,甚至可以在同一台机器上开一个小的redis

1. 本地缓存

img

2. 业务脚本

我们已经习惯了使用缓存集群对数据做缓存,但是这种常见的内存缓存服务有很多不方便的地方,比如集群会独占大量的内存、不能原子修改缓存的某一个字段、多次通讯有网络损耗。

很多时候我们获取数据并不需要全部字段,但因为缓存不支持筛选,批量获取数据的场景下性能就会下降很多。这些问题在读多写多的场景下,会更加明显。

2.1. 缓存即服务

img

通过缓存预热,把缓存中的数据用脚本处理后再放到本地的Map、Set等,可以对其直接进行操作,避免用的时候再

3. 流量拆分

一般来说,这种服务多数属于实时互动服务,因为时效性要求很高,导致很多场景下,我们无法用读缓存的方式来降低核心数据的压力。所以,为了降低这类互动服务器的压力,我们可以从架构入手,做一些灵活拆分的设计改造。

3.1. 可预估用户量的服务

3.2. 不可预估用户量的服务

3.2.1. 聊天:信息合并

点赞或大量用户输入同样内容的刷屏情境可以合并信息,压缩整理后的聊天内容会被分发到多个聊天内容分发服务器上,直播间内用户的聊天长连接会收到消息更新的推送通知,接着客户端会到指定的内容分发服务器群组批量拉取数据,拿到数据后会根据时间顺序来回放。请注意,这个方式只适合用在疯狂刷屏的情况,如果用户量很少可以通过长链接进行实时互动。

img

3.2.2. 答题:瞬时信息拉取高峰

除了交互流量极大的聊天互动信息之外,还有一些特殊的互动,如做题互动。直播间老师发送一个题目,题目消息会广播给所有用户,客户端收到消息后会从服务端拉取题目的数据。

如果有10w用户在线,很有可能导致瞬间有10w人在线同时请求服务端拉取题目。这样的数据请求量,需要我们投入大量的服务器和带宽才能承受,不过这么做这个性价比并不高。

理论上我们可以将数据静态化,并通过CDN阻挡这个流量,但是为了避免出现瞬时的高峰,推荐客户端拉取时加入随机延迟几秒,再发送请求,这样可以大大延缓服务器压力,获得更好的用户体验。

切记对于客户端来说,这种服务如果失败了,就不要频繁地请求重试,不然会将服务端打沉。如果必须这样做,那么建议你对重试的时间做退火算法,以此保证服务端不会因为一时故障收到大量的请求,导致服务器崩溃。

如果是教学场景的直播,有两个缓解服务器压力的技巧。第一个技巧是在上课当天,把抢答题目提前交给客户端做预加载下载,这样可以减少实时拉取的压力。

第二个方式是题目抢答的情况,老师发布题目的时候,提前设定发送动作生效后5秒再弹出题目,这样能让所有直播用户的接收端“准时”地收到题目信息,而不至于出现用户题目接收时间不一致的情况。

至于非抢答类型的题目,用户回答完题目后,我们可以先在客户端本地先做预判卷,把正确答案和解析展示给用户,然后在直播期间异步缓慢地提交用户答题结果到服务端,以此保证服务器不会因用户瞬时的流量被冲垮。

3.2.3. 点赞:服务端树形汇总

一致性要求不高的计数器

img

这个方式可以将用户点赞流量随机压到不同的写缓存服务上,通过第一层写缓存本地的实时汇总来缓解大量用户的请求,将更新数据周期性地汇总后,提交到二级写缓存。

之后,二级汇总所在分片的所有上层服务数值后,最终汇总同步给核心缓存服务。接着,通过核心缓存把最终结果汇总累加起来。最后通过主从复制到多个子查询节点服务,供用户查询汇总结果。

3.2.4. 打赏&购物:服务端分片及分片实时扩容

具有强一致性

因为事务一致性的要求,这种服务我们不能做成多层缓冲方式提供服务,而且这种服务的数据特征是读多写多,所以我们可以通过数据分片方式实现这一类服务

img

分片算法

一致性哈希: 一致性哈希让集群扩容变简单,但你很难“挑选”哪些数据去哪个节点,需要额外工具,否则控制起来麻烦。

树形热迁移切片法: 虚拟桶,把数据分片到桶上,然后一个服务器管理几个桶,满足动态扩容要求

3.3. 服务降级:分布式队列汇总缓冲

限流,利用队列将消息合并 牺牲实时性

4. 流量调度:DNS、全站加速及机房负载均衡

第五章 写多读少的系统

1. 稀疏索引

这节课,我们讨论了OLAP和OLTP数据库的索引、存储、数据量以及应用的不同场景。

OLAP相对于关系数据库的数据存储量会更多,并且对于大量数据批量写入支持很好。很多情况下,高并发批量写数据很常见,其表的字段会更多,数据的存储多数是用列式方式存储,而数据的索引用的则是列索引,通过这些即可实现实时大数据计算结果的查询和分析。

相对于离线计算来说,这种方式更加快速方便,唯一的缺点在于这类服务都需要多台服务器做分布式,成本高昂。

可以看出,我们使用的场景不同决定了我们的数据底层如何去做更高效,HTAP的出现,让我们在不同的场景中有了更多的选择,毕竟大数据挖掘是一个很庞大的数据管理体系,如果能有一个轻量级的OLAP,会让我们的业务拥有更多的可能。

第六章 内网服务设计

1. 统一缓存数据平台

1.1. 实体数据主动缓存

imgimg

双写:缓存的内容是多个表聚合来的,写通常比删的开销要大

这个方案服务在写的时候是先更再删,中间件负责监听以及回填缓存(需要写回填的脚本),消息队列保证可靠性

1.2. L1缓存及热点缓存延期

img

1.3. 关系数据缓存

为此,我们首先需要改进消息监听服务,将它做成Kafka Group Consumer服务,同时实现可动态扩容,这能提升系统的并行数据处理能力,支持更大量的并发修改。

其次,对于量级更高的数据缓存系统,还可以引入多种数据引擎共同提供不同的数据支撑服务,比如:

  • lua脚本引擎(具体可以回顾第十七节课)是数据推送的“发动机”,能帮我们把数据动态同步到多个数据源;
  • Elasticsearch负责提供全文检索功能;
  • Pika负责提供大容量KV查询功能;
  • ClickHouse负责提供实时查询数据的汇总统计功能;
  • MySQL引擎负责支撑新维度的数据查询。

1.4. 多数据引擎平台

img