Featured image of post 读《凤凰架构》有感--架构篇

读《凤凰架构》有感--架构篇

最近攻读周志明老师的《凤凰架构》,书中介绍了软件领域的架构演进,涉及到众多架构知识,分布式场景的设计原则等,不知能否做到归纳总结。\n本文主要包含:服务远程调用、各种事务、流量分发、架构安全

凤凰架构

文中大部分资料摘抄自周志明老师的凤凰架构开源网站

架构

访问远程服务

远程服务调用(RPC)

RPC出现的目的是为了让计算机能够跟调用本地方法一样去调用远程方法。

进程间通讯(IPC)

  1. 管道(pipe)

    管道类似于两个进程间的桥梁,可以通过管道在进程间传递少量的字符流或字节流。

    管道命令:

    1
    
    ps -ef | grep java
    
  2. 信号(Signal)

    用于通知目标某种事件发生,除了用于进程间通信外,进程还可以发送信号给进程自身。

    信号命令:

    1
    
    kill -9 pid
    
  3. 信号量(Semaphore)

    两个进程之间同步协作的手段,相同于操作系统提供的一个特殊变量,程序可以在上面进行wait()和notify()操作。

  4. 消息队列(Message Queue):上面的三个方式只适合传递少量信息,消息队列用于进程间数据量较多的通信。

  5. 共享内存(Shared Memory):允许多个进程访问同一块公共的内存空间,这是效率最高的进程间通信方式。

  6. 套接字接口(Socket):可用于不同机器之间的进程通信。

img

RPC的三个基本问题

  • 如何表示数据(序列化与反序列化)

将交互双方锁涉及的数据转换为某种事先约定好的中立数据流格式来进行传输,将输数据流转换回不同语言中对应的数据类型来进行使用。

  • 如何传递数据

两个服务的Endpoint之间交互操作、交换数据。一般是基于标准的TCP、UDP等标准的传输层协议来完成的。(也有可能直接使用HTTP协议来实现)

  • 如何确定方法

编译器或者解释器回根据语言规范,将调用的方法签名传唤为进程空间中子过程入口位置的指针。

REST设计风格(表征状态转移)

并非协议,只是一种风格

RPC:面向过程编程 REST:面向资源编程

  • 资源(Resource):内容本身,如信息、数据等。远程调用等都是为了提供资源。
  • 表征(Representation):信息与用户交互时的表示形式,这与软件分层架构中常说的表示层的语义一致。
  • 状态(State):分成有状态与无状态,由服务端保存用户目前所处的阶段或状态为有状态,由用户自己保存自己目前所处的状态,服务端只负责提供资源的为无状态。
  • 转移(Transfer):服务端通过某种方式让用户的状态发生改变,如获取了新资源等。这个操作被称为:表征状态转移

REST风格的六大原则

  1. 服务端与客户端分离(Client-Server)前后端分离

    将用户界面所关注的逻辑和数据存储所关注的逻辑分离开来,有助于提高用户界面的跨平台的可移植性。

  2. 无状态(Stateless)去除session

    REST希望服务器不要去负责维护状态,每一次从客户端发送的请求中,应包括所有必要的上下文信息,会话信息也由客户端负责保存维护,服务端依据客户端传递的状态来执行业务处理逻辑,驱动整个应用的状态变迁。

    目前大部分系统达不到这个要求,服务端无状态可以在分布式计算中获得非常高的价值,但即希望于用户每次传输大量的上下文有点不切实际。

  3. 可缓存(Cacheability)分布式缓存

    无状态服务器虽然提升了系统的可见性、可靠性和可伸缩性,但降低了系统的网络性。REST希望分布式系统有一个可以暂时缓存数据的分布式缓存(Redis),这样服务器直接交互,可以进一步提高性能。

  4. 分层系统(Layered System)(负载均衡)

    客户端不需要直到是否直接连接到了最终的服务器,中间服务器可以通过负载均衡和共享缓存的机制提高系统的可扩展性,这样便于缓存、伸缩和安全策略的部署。

  5. 统一接口(Uniform Interface)(面向资源)

    REST希望开发者面向资源编程,希望软件系统设计的重点放在抽象系统该有哪些资源上,而不是抽象系统该有哪些行为(服务)上。

    用一个登录场景来举例子:

    传统思维:登录login()服务,注销logout()服务。

    REST思维:登录,PUT Session,注销DELETE Session。查询登录信息,GET Session

    所有的资源最好应该是自描述信息的,或都是通过资源id来进行的。

  6. 按需代码(Code-On-Demand)(可选项)

    客户端无需直到服务端如何运行,服务端会按需把需要的可执行程序发送给客户端执行。

RMM成熟度

如何衡量一个服务有多么REST,下面直接引入书中的内容

  1. 完全不REST

    医院开放了一个/appointmentService的 Web API,传入日期、医生姓名作为参数,可以得到该时间段该名医生的空闲时间,该 API 的一次 HTTP 调用如下所示:

    1
    2
    3
    
    POST /appointmentService?action=query HTTP/1.1
    
    {date: "2020-03-04", doctor: "mjones"}
    

    然后服务器会传回一个包含了所需信息的回应:

    1
    2
    3
    4
    5
    6
    
    HTTP/1.1 200 OK
    
    [
    	{start:"14:00", end: "14:50", doctor: "mjones"},
    	{start:"16:00", end: "16:50", doctor: "mjones"}
    ]
    

    得到了医生空闲的结果后,我觉得 14:00 的时间比较合适,于是进行预约确认,并提交了我的基本信息:

    1
    2
    3
    4
    5
    6
    
    POST /appointmentService?action=confirm HTTP/1.1
    
    {
    	appointment: {date: "2020-03-04", start:"14:00", doctor: "mjones"},
    	patient: {name: icyfenix, age: 30, ……}
    }
    

    如果预约成功,那我能够收到一个预约成功的响应:

    1
    2
    3
    4
    5
    6
    
    HTTP/1.1 200 OK
    
    {
    	code: 0,
    	message: "Successful confirmation of appointment"
    }
    

    如果发生了问题,譬如有人在我前面抢先预约了,那么我会在响应中收到某种错误信息:

    1
    2
    3
    4
    5
    6
    
    HTTP/1.1 200 OK
    
    {
    	code: 1
    	message: "doctor not available"
    }
    

    到此,整个预约服务宣告完成,直接明了,我们采用的是非常直观的基于 RPC 风格的服务设计似乎很容易就解决了所有问题……了吗?

  2. Resource:开始引入资源的概念

    第 0 级是 RPC 的风格,如果需求永远不会变化,也不会增加,那它完全可以良好地工作下去。但是,如果你不想为预约医生之外的其他操作、为获取空闲时间之外的其他信息去编写额外的方法,或者改动现有方法的接口,那还是应该考虑一下如何使用 REST 来抽象资源。

    通往 REST 的第一步是引入资源的概念,在 API 中基本的体现是围绕着资源而不是过程来设计服务,说的直白一点,可以理解为服务的 Endpoint 应该是一个名词而不是动词。此外,每次请求中都应包含资源的 ID,所有操作均通过资源 ID 来进行,譬如,获取医生指定时间的空闲档期:

    1
    2
    3
    
    POST /doctors/mjones HTTP/1.1
    
    {date: "2020-03-04"}
    

    然后服务器传回一组包含了 ID 信息的档期清单,注意,ID 是资源的唯一编号,有 ID 即代表“医生的档期”被视为一种资源:

    1
    2
    3
    4
    5
    6
    
    HTTP/1.1 200 OK
    
    [
    	{id: 1234, start:"14:00", end: "14:50", doctor: "mjones"},
    	{id: 5678, start:"16:00", end: "16:50", doctor: "mjones"}
    ]
    

    我还是觉得 14:00 的时间比较合适,于是又进行预约确认,并提交了我的基本信息:

    1
    2
    3
    
    POST /schedules/1234 HTTP/1.1
    
    {name: icyfenix, age: 30, ……}
    

    后面预约成功或者失败的响应消息在这个级别里面与之前一致,就不重复了。比起第 0 级,第 1 级的特征是引入了资源,通过资源 ID 作为主要线索与服务交互,但第 1 级至少还有三个问题并没有解决:一是只处理了查询和预约,如果我临时想换个时间,要调整预约,或者我的病忽然好了,想删除预约,这都需要提供新的服务接口。二是处理结果响应时,只能靠着结果中的codemessage这些字段做分支判断,每一套服务都要设计可能发生错误的 code,这很难考虑全面,而且也不利于对某些通用的错误做统一处理;三是并没有考虑认证授权等安全方面的内容,譬如要求只有登陆用户才允许查询医生档期时间,某些医生可能只对 VIP 开放,需要特定级别的病人才能预约,等等。

  3. HTTP Verbs:引入统一接口,映射到HTTP协议

    第 1 级遗留三个问题都可以靠引入统一接口来解决。HTTP 协议的七个标准方法是经过精心设计的,只要架构师的抽象能力够用,它们几乎能涵盖资源可能遇到的所有操作场景。REST 的做法是把不同业务需求抽象为对资源的增加、修改、删除等操作来解决第一个问题;使用 HTTP 协议的 Status Code,可以涵盖大多数资源操作可能出现的异常,而且 Status Code 可以自定义扩展,以此解决第二个问题;依靠 HTTP Header 中携带的额外认证、授权信息来解决第三个问题,这个在实战中并没有体现,请参考安全架构中的“凭证”相关内容。

    按这个思路,获取医生档期,应采用具有查询语义的 GET 操作进行:

    1
    
    GET /doctors/mjones/schedule?date=2020-03-04&status=open HTTP/1.1
    

    然后服务器会传回一个包含了所需信息的回应:

    1
    2
    3
    4
    5
    6
    
    HTTP/1.1 200 OK
    
    [
    	{id: 1234, start:"14:00", end: "14:50", doctor: "mjones"},
    	{id: 5678, start:"16:00", end: "16:50", doctor: "mjones"}
    ]
    

    我仍然觉得 14:00 的时间比较合适,于是又进行预约确认,并提交了我的基本信息,用以创建预约,这是符合 POST 的语义的:

    1
    2
    3
    
    POST /schedules/1234 HTTP/1.1
    
    {name: icyfenix, age: 30, ……}
    

    如果预约成功,那我能够收到一个预约成功的响应:

    1
    2
    3
    
    HTTP/1.1 201 Created
    
    Successful confirmation of appointment
    

    如果发生了问题,譬如有人在我前面抢先预约了,那么我会在响应中收到某种错误信息:

    1
    2
    3
    
    HTTP/1.1 409 Conflict
    
    doctor not available
    
  4. 超文本驱动的REST接口

第 2 级是目前绝大多数系统所到达的 REST 级别,但仍不是完美的,至少还存在一个问题:你是如何知道预约 mjones 医生的档期是需要访问/schedules/1234这个服务 Endpoint 的?也许你甚至第一时间无法理解为何我会有这样的疑问,这当然是程序代码写的呀!但 REST 并不认同这种已烙在程序员脑海中许久的想法。RMM 中的 Hypermedia Controls、Fielding 论文中的 HATEOAS 和现在提的比较多的“超文本驱动”,所希望的是除了第一个请求是由你在浏览器地址栏输入所驱动之外,其他的请求都应该能够自己描述清楚后续可能发生的状态转移,由超文本自身来驱动。所以,当你输入了查询的指令之后:

1
GET /doctors/mjones/schedule?date=2020-03-04&status=open HTTP/1.1

服务器传回的响应信息应该包括诸如如何预约档期、如何了解医生信息等可能的后续操作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
HTTP/1.1 200 OK

{
	schedules:[
		{
			id: 1234, start:"14:00", end: "14:50", doctor: "mjones",
			links: [
				{rel: "comfirm schedule", href: "/schedules/1234"}
			]
		},
		{
			id: 5678, start:"16:00", end: "16:50", doctor: "mjones",
			links: [
				{rel: "comfirm schedule", href: "/schedules/5678"}
			]
		}
	],
	links: [
		{rel: "doctor info", href: "/doctors/mjones/info"}
	]
}

如果做到了第 3 级 REST,那服务端的 API 和客户端也是完全解耦的,你要调整服务数量,或者同一个服务做 API 升级将会变得非常简单。

备注:个人认为这样开发过于夸张,但不可否认,通过动态的返回可选的url,可以强大的实现权限控制,功能扩展等。

REST的不足

  1. 面向资源编程只适合CRUD,面向过程、面向对象编程才能处理真正复杂的业务
  2. REST和HTTP完全绑定,不适合应用于要求高性能传输的场景
  3. REST不利于事务支持
  4. REST没有传输可靠性支持
  5. REST缺乏对资源进行“部分”和“批量”的处理能力

事务处理

  • 事务的ACID

C:一致性,一致性不是维持事务的手段,而是我们需要达到的目的。如果保证事务的一致性,只有完成了其他三种手段,才能保证事务的一致性。

下面是源文的原话:

A、I、D 是手段,C 是目的,前者是因,后者是果,弄到一块去完全是为了拼凑个单词缩写。

A原子性:在同一项业务处理过程中,事务保证了对多个数据的修改,要么同时成功,要么同时被撤销。

I隔离性:在不同的业务处理过程中,事务保证了各自业务正在读写的数据互相独立,不会彼此影响。

D持久性:事务应当保证所有成功被提交的数据修改都能正确地被持久化,不丢数据。

  • 内部一致性

一个服务只使用了一个数据源时,通过AID来保证一致性。

  • 外部一致性

一个服务使用多个不同的数据源,甚至多个服务同时涉及多个不同的数据源。

本地事务(局部事务)

仅操作单一事务资源的、不需要全局事务管理器进行协调的事务。

  • 原子性和持久性

原子性:要么都生效,要么都不生效,不存在中间状态。

持久性:一旦事务生效,就不会再因为任务原因而导致其修改的内容被撤销或丢失。

  • 原子性:

未提交事务,写入后崩溃:如果修改进行了一部分,但事务没有提交程序就崩溃了。程序一旦重启,数据库必须要有办法得知崩溃前发生过一次不完整的购物操作,将已经修改过的数据从磁盘中恢复成没有修改过的样子。保证原子性。

mysql的做法:通过MVCC,事务在修改时,会先生成对应的undolog版本链,达到记录每次状态的目的。如果遇到上述情况,可以通过版本链来选择需要恢复的版本。(实现事务的回滚操作)

  • 持久性:

已提交事务,写入前崩溃:程序已经完成了修改,提交了事务,但还没有把修改后的结果都写入到磁盘中,此时出现了崩溃。程序一旦重启后,数据库必须要有办法得知崩溃前发生过一次完整的操作,将没来得及写入磁盘的部分重新写入磁盘,保证持久性。

mysql的做法:通过redo log,事务提交前必须保证redo log已写入完毕,就算事务提交但数据没有落盘。也可以保证在重启时通过redo log来加载到修改的变量。 同时redolog也缩小了刷盘的次数和每次需要修改更新的数据量,不需要一次性读取更新整页的数据。减少了成本

以日志的形式——即仅进行顺序追加的文件写入的形式(这是最高效的写入方式)先记录到磁盘中。只有在日志记录全部都安全落盘,数据库在日志中看到代表事务成功提交的“提交记录”(Commit Record)后,才会根据日志上的信息对真正的数据进行修改,修改完成后,再在日志中加入一条“结束记录”(End Record)表示事务已完成持久化,这种事务实现方法被称为“Commit Logging”(提交日志)。

  • 另一种保证持久性和原子性的方式Shadow Paging

对数据的变动会写到硬盘的数据中,但并不是直接就地修改原先的数据,而是先将数据复制一份副本,保留原数据,修改副本数据。

当事务成功提交,所有数据的修改都成功持久化之后,最后一步是去修改数据的引用指针,将引用从原数据改为新复制出来修改后的副本,最后的“修改指针”这个操作将被认为是原子操作,现代磁盘的写操作可以认为在硬件上保证了不会出现“改了半个值”的现象。所以 Shadow Paging 也可以保证原子性和持久性。

以上方式和copyonwritelist的实现非常像。

  • FORCE:当事务提交后,要求变动数据必须同时完成写入则称为FORCE,不强制要求同时写入为NO-FORCE。
  • STEAL:事务提交前,允许变动数据提前写入则称为STEAL,不允许则称为NO-STEAL。

下图中,左边为磁盘IO性能考虑,右边为想要达到效果需要用到的日志。

1754632791295.png

  • 隔离性

保证每个事务各自读、写的数据相互独立,不会彼此影响。

这里只突出讲解不同隔离级别下是否需要锁和对应的上锁范围,并不突出mysql的锁。如果想详细了解mysql的锁,可以看我的这篇文章

现代数据库提供的三种锁:

  1. 写锁(X锁):只有持有写锁的数据才能对数据进行写入操作,数据被加写锁时,其他事务不能写入数据,也不能施加读锁。

  2. 读锁(S锁):多个事务对同一个数据加多个读锁,只要数据上还有读锁,就不能再加写锁,其他事务也不能对该数据进行写入,但仍然可以读取。如果数据只有当前事务自己添加了读锁,可以把读锁升级成写锁,然后写入数据。

  3. 范围锁:对与某个范围上锁,实现多种多样。在这个范围内的数据不能被写入。

    1
    
    SELECT * FROM books WHERE price < 100 FOR UPDATE;
    
  • 隔离性的差异
  1. 串行化:同一时间只能存在一个事务,其他事务需要等待当前事务执行后才能开启。

    天生具有隔离性,不需对数据加任何锁。但性能极差,没有并发能力。

  2. 可重复读(RR):只对事务所涉及到的数据加读锁或者写锁,且一直持有锁到事务结束。但任可能产生幻读问题。事务在执行过程中,两个完全相同的范围查询得到的结果集不一致。

    1
    2
    3
    
    SELECT count(1) FROM books WHERE price < 100					/* 时间顺序:1,事务: T1 */
    INSERT INTO books(name,price) VALUES ('深入理解Java虚拟机',90)	/* 时间顺序:2,事务: T2 */
    SELECT count(1) FROM books WHERE price < 100					/* 时间顺序:3,事务: T1 */
    

    可重复读没有范围锁来禁止对该范围内插入新的数据,导致隔离性被破坏。

    mysql在可重复读级别下,只读事务完全可以避免幻读问题。但在读写事务下,依然可能出现幻读问题。(MVCC并不能完美解决幻读)

    如:事务 T1 如果在其他事务插入新书后,不是重新查询一次数量,而是要将所有小于 100 元的书改名,那就依然会受到新插入书籍的影响。

  3. 读已提交(RC):写锁会一直持续到事务结束,但读锁会在每次查询操作结束后就会立刻释放。会产生不可重复读问题。同一行数据的两次查询得到了不同的结果。

    读已提交缺乏整个周期性的读锁,无法禁止读取过的数据发生变化。隔离性被破坏的表现。

  4. 读未提交(RU):对事务涉及的数据只加写锁一直持续到事务结束,但完全不加读锁。会产生脏读问题。一个事务读取到另一个事务未提交的数据。

理论还存在更低的隔离性,就是事务既不加读锁,也不加写锁。

不同隔离级别产生的问题只是表面现象,是各种锁在不同加锁时间上组合而产生的结果,以锁为手段来实现隔离性才是数据库表现出不同隔离级别的根本原因。

  • MVCC(只针对读+写场景)

  • 插入数据时:CREATE_VERSION 记录插入数据的事务 ID,DELETE_VERSION 为空。

  • 删除数据时:DELETE_VERSION 记录删除数据的事务 ID,CREATE_VERSION 为空。

  • 修改数据时:将修改数据视为“删除旧数据,插入新数据”的组合,即先将原有数据复制一份,原有数据的 DELETE_VERSION 记录修改数据的事务 ID,CREATE_VERSION 为空。复制出来的新数据的 CREATE_VERSION 记录修改数据的事务 ID,DELETE_VERSION 为空。

此时,如有另外一个事务要读取这些发生了变化的数据,将根据隔离级别来决定到底应该读取哪个版本的数据。

  • 隔离级别是可重复读:总是读取 CREATE_VERSION 小于或等于当前事务 ID 的记录,在这个前提下,如果数据仍有多个版本,则取最新(事务 ID 最大)的。
  • 隔离级别是读已提交:总是取最新的版本即可,即最近被 Commit 的那个版本的数据记录。

另外两个隔离级别都没有必要用到 MVCC,因为读未提交直接修改原始数据即可,其他事务查看数据的时候立刻可以看到,根本无须版本字段。可串行化本来的语义就是要阻塞其他事务的读取操作,而 MVCC 是做读取时无锁优化的,自然就不会放到一起用。

mysql利用undolog和锁来实现MVCC

全局事务(外部事务)

单个服务使用多个数据源场景的事务解决方案。

全局事务和本地事务代码表现得不同:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public void buyBook(PaymentBill bill) {
    userTransaction.begin();
    warehouseTransaction.begin();
    businessTransaction.begin();
	try {
        userAccountService.pay(bill.getMoney());
        warehouseService.deliver(bill.getItems());
        businessAccountService.receipt(bill.getMoney());
        userTransaction.commit();
        warehouseTransaction.commit();
        businessTransaction.commit();
	} catch(Exception e) {
        userTransaction.rollback();
        warehouseTransaction.rollback();
        businessTransaction.rollback();
	}
}

开启三个事务,提交三个事务,或回滚三个事务。保证多个事务要么全部成功,要么全部失败。

  • 2PC协议
  1. 准备阶段:投票阶段,协调者询问事务的所有参与者是否准备好提交。
  2. 提交阶段:执行阶段,协调者如果在上一个阶段收到所有事务参与者回复的可提交消息,则先自己在本地持久化事务为commit状态,然后给所有参与者发送commit指令。所有参与者立刻执行提交操作。

sequenceDiagram 协调者 -»+ 参与者: 要求所有参与者进入准备阶段 参与者 –»- 协调者: 已进入准备阶段 协调者 -»+ 参与者: 要求所有参与者进入提交阶段 参与者 –»- 协调者: 已进入提交阶段 opt 失败或超时 协调者 -»+ 参与者: 要求所有参与者回滚事务 参与者 –»- 协调者: 已回滚事务 end

:::center 图 3-1 两段式提交的交互时序示意图 :::

1754644931270.png

2PC能够保证一致性的前提条件

  1. 网络在提交阶段必须是可靠的,不能在提交阶段丢失消息。如果投票阶段失败还可以执行回滚操作,但如果提交阶段失败就无法补救。
  2. 必须假设在网络分区、机器崩溃或者其他原因导致的节点失联最终能够回复,不会永久性地失联。

2PC的缺点

  1. 单点问题:协调者在两阶段提交中具有举足轻重的作用,协调者在等待参与者回复时可以有超时机制,允许参与者宕机,但参与者等待协调者指令时无法做超时处理。

    如果协调者宕机,就没法正常发送Commit或者RollBack指令,所有参与者都必须一直等待。

  2. 性能问题:所有参与者都相当于绑定成一个统一调度的整体,期间要经过两次远程服务调用,三次数据持久化(准备阶段写重做日志,协调者做状态持久化,提交阶段在日志写入 Commit Record)。必须等待执行最慢的参与者执行完毕后,才算事务提交,所以性能较差。

  3. 一致性风险:尽管提交阶段时间很短,但这仍是一段明确存在的危险期,如果协调者在发出准备指令后,根据收到各个参与者发回的信息确定事务状态是可以提交的,协调者会先持久化事务状态,并提交自己的事务,如果这时候网络忽然被断开,无法再通过网络向所有参与者发出 Commit 指令的话,就会导致部分数据(协调者的)已提交,但部分数据(参与者的)既未提交,也没有办法回滚,产生了数据不一致的问题。

    一部分提交了事务,一部分未提交,导致事务无法回滚。产生数据不一致问题。

  • 3PC协议

1754645818202.png

三段式提交对单点问题和回滚时的性能问题有所改善,但是它对一致性风险问题并未有任何改进,在这方面它面临的风险甚至反而是略有增加了的。譬如,进入 PreCommit 阶段之后,协调者发出的指令不是 Ack 而是 Abort,而此时因网络问题,有部分参与者直至超时都未能收到协调者的 Abort 指令的话,这些参与者将会错误地提交事务,这就产生了不同参与者之间数据不一致的问题。

共享事务

多个服务共用同一个数据源。伪需求,不应当存在的场景

使用一个公共的交易服务器来与数据库连接,无论上游有多少个不同的服务器,都需要请求交易服务器来实现数据库操作。从而达到共享事务的效果。(但使原本分散的负载又重新聚合了)

1754882685058.png

将多个服务的事务,通过聚合到一个服务代理,转换成一个本地事务

使用消息队列让多个服务来处理任务也算是共享事务的一个变种

分布式事务

多个服务同时访问多个数据源的事务处理机制,在分布式创建下的事务处理机制。

  • CAP

img

  1. 一致性(C):数据在任何时刻、任何分布式节点中所看到的都是符合逾期的。
  2. 可用性(A):系统不间断地提供服务的能力。
    1. 可靠性:根据平均无故障时间来度量
    2. 可维护性:平均可修复时间来度量
  3. 分区容错性(P):分布式环境中,部分节点因网络原因而彼此失联后,与其他节点形成网络分区,系统仍能正确地提供服务的能力。

如何取舍CAP

  • 如果放弃分区容错性(CA):所有节点之间的通讯永远都是可靠的。(永远可靠的通讯在分布式场景下必定不存在)不可能的选择

  • 如果放弃可用性(CP):一旦网络发生了分区,节点之间的信息同步可以无限制的延长。类似于前面的全局事务一致性问题,可以通过2PC/3PC手段,来获得分区容错性和一致性。

    CP下系统一般用于对数据质量要求很高的场景中。如果发生错误,服务就下线,不再提供服务,等待恢复后才提供服务。

  • 如果放弃一致性(AP):一旦发生分区,节点之间提供的数据可能不一致。(目前分布式系统的主流选择)

    高可用一般是一个分布式系统建立的主要目的,如果为了保证一致性而放弃高可用,那不如不做分布式(银行类的金融系统除外)

事务出现的初衷就是为了保证一致性,在AP分布式场景下,一致性反而无法得到保证。于是为了回到初衷,又提出了最终一致性的概念。

  • 最终一致性:如果数据在一段时间之内没有被另外的操作所更改,那它最终将会达到与强一致性过程相同的结果。

可靠事件队列(最大努力交付)

1754891613491.png

目前需要做三个操作,账号扣款、商家账号收款、库存商品出库。

三个操作中账号扣款最容易出现问题,其次扣库存容易出错,收款环节最不容易出现问题。

一般设计时把最容易出现问题的操作放在最前面,这样出现问题回滚的代价最小。

  1. 扣款:先开启一个本地事务,进行扣款和写入消息操作。如果扣款成功,就在自己本地的数据库中建立一张消息表,存入一条消息,状态为:扣款已完成,出库进行中、收款进行中。

    如果扣款过程出现错误,就不需要进行写入消息等操作。利用一个本地事务完成最大规模的筛选。

    扣款成功后,后面的扣减库存和收款操作没有先后顺序,可以同时进行。

  2. 收款成功、扣减库存成功:把两个消息状态都修改成已完成,整个事务结束,达到最终一致性。

  3. 扣减库存或收款出现网络问题:账号服务一直没有收到消息。账号服务一直重复向未响应的服务重复发送消息。(扣减库存和收款服务一定要实现幂等性)可以同每个事务唯一的事务ID来实现幂等。

  4. 库存服务或收款服务无法完成工作:没有库存或者无法收款。账号服务会不断重复发送消息直到成功为止。或者人工介入处理。通过事件队列来处理的分布式事务没有失败回滚的概念,只许成功,不许失败。

  5. 收款和库存服务都成功后,由于网络原因导致回复给账号服务的消息丢失:账号服务会不断地重复给收款和库存服务发送消息,由于已做幂等操作,收到重复消息后,收款和库存服务会再次给账号服务发送成功消息。

TCP协议中,如果未收到ACK应答自动重新发送包的可靠性保障就属于最大努力交付。

  • 缺点
  1. 可靠消息会不断的重试操作,会造成大量无畏消耗。
  2. 所有操作只许成功,如果必定会失败,就会无限重试。死循环。
  3. 没有任何隔离性可言,可能会出现“超售”情况。每个人购买的数量都没有超过最大数量,但加起来超过了最大数量。(会导致一个事务无限重试)

TCC事务

Try-Confirm-Cancel,如果事务需要隔离性,应重点考虑TCC。但对业务入侵性较强。

  1. Try:尝试执行阶段,完成所有业务可执行性的检查(保证一致性),并且预留好全部需要用到的业务资源(保证隔离性)。
  2. Confirm:确认执行阶段,不进行任何业务检查,直接使用Try阶段准备的资源来完成业务处理。Confirm阶段可能会重复执行,此阶段需要做幂等性校验。
  3. Cancel:取消执行阶段,释放Try阶段预留的业务资源。Cancel阶段可能会重复执行,需要幂等性。

1754900636872.png

业务场景与上方相同:

  1. 创建事务,生成事务ID,记录到日志表中,进入Try阶段(一下所有服务调用没有先后顺序):
    1. 用户服务:检查业务可行性,可行,将用户100元设置为冻结状态,(占用额度),通知下一步进入Confirm阶段;不可行,通知下一步进入Cancel阶段。
    2. 仓库服务:检查可行性,可行,冻结库存1(占用),通知下一步进入Confirm阶段;不可行,通知下一步进入Cancel阶段
    3. 商家服务:检查业务可行性,不需要冻结资源。
  2. 如果所有业务都可行,进入Confirm阶段:
    1. 用户服务:完成操作。进行扣款。
    2. 仓库服务:按冻结数量扣减库存。
    3. 商家服务:收款。
  3. 全部完成后,事务正式结束,如果2中任意步骤出现异常,都需要重新执行Confirm操作,进行最大努力交付。
  4. 如果1中业务任意不可行,或任意一个服务超时,则将活动日志置为Cancel,进入Cancel阶段:
    1. 用户服务:取消业务,释放冻结的100元
    2. 仓库服务:取消业务,释放冻结的库存
    3. 商家服务:取消业务,(大哭一场,然后安慰商家谋生不易:-))
  5. 如果4全部成功,事务最终置为失败,如果4中任意操作出现异常,就重复发送Concel操作,进行最大努力交付。
  • 优点

TCC与2PC逻辑类似,但TCC所有的操作都只操作预留的资源(预冻结或占用的资源),天生具有隔离性,几乎不涉及锁和资源的争用,拥有更好的性能。

  • 缺点

开发成本高,对业务的侵入性较大,更大的更换成本。

推荐使用阿里开源的Seata来减少TCC代码开发的编码工作量。

SAGA事务(一长串事件、长篇故事)

用来提升长时间事务运作效率的方法。避免大事务长时间锁定数据库的资源,将大事务分解成一系列本地事务的设计模式。

性能最好,适用于无法使用Try阶段的事务,如目前盛行的网络支付,直接从银行转账等类似场景。(无法进行冻结、占用等操作)

  • 大事务拆分成若干小事务,将整个分布式事务T分解成为n个子事务,命名为 T1,T2,…,Ti,…,Tn。每个子事务都应该是或者能被视为是原子行为。
  • 为每个子事务设计对应的补偿动作,命名为 C1,C2,…,Ci,…,Cn。Ti与 Ci必须满足以下条件:
    • Ti和Ci都具备幂等性
    • Ti和Ci满足交换律,先执行Ti还是先执行Ci,效果都是一样的
    • Ci必须能成功提交,不能存在Ci本身提交失败回滚的情况,如果出现就必须持续重试直到成功,或者直接人工介入。

如果所有的Ti都成功提交,那事务顺利完成,否则执行恢复策略

  • 正向恢复:如果Ti事务提交失败,则一直对Ti进行重试,直到成功为止(最大努力交付)。不需要补偿,适用于事务最终都要成功的场景。

    正向恢复的执行模式为:T1,T2,…,Ti(失败),Ti(重试)…,Ti+1,…,Tn。

  • 反向恢复:如果Ti事务提交失败,则一直执行Ci对Ti进行补偿,直到成功为止(最大努力交付)。Ci必须执行成功。

    反向恢复的执行模式为:T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1。

与TCC对比,SAGA不需要为资源设计冻结状态和撤销冻结的操作,补偿要比冻结操作容易得多。

譬如,前面提到的账号余额直接在银行维护的场景,从银行划转货款到 Fenix’s Bookstore 系统中,这步是经由用户支付操作(扫码或 U 盾)来促使银行提供服务;如果后续业务操作失败,尽管我们无法要求银行撤销掉之前的用户转账操作,但是由 Fenix’s Bookstore 系统将货款转回到用户账上作为补偿措施却是完全可行的。

透明多级分流系统

如何分配流量,均衡负载等。

客户端缓存

分为强制缓存和协商缓存

  • 强制缓存

浏览器本地设置的缓存,不用与服务器交互就可以得知什么时候需要失效

参数说明
max-age / s-maxage相对时间控制缓存有效期,避免客户端时间误差
public / private是否允许代理或 CDN 缓存
no-cache / no-store禁止缓存或禁止保存资源
no-transform禁止 CDN 等修改资源
min-fresh / only-if-cached控制客户端请求行为
must-revalidate / proxy-revalidate资源过期后必须重新验证
  • 协商缓存

资源过期之后,通过与服务器交互来判断是否重新获取资源

两种机制

  1. Last-Modified / If-Modified-Since
    • 通过资源最后修改时间判断是否更新。
    • 精度有限,可能误判。
  2. ETag / If-None-Match
    • 通过资源唯一标识判断是否更新。
    • 一致性强,但服务器计算开销大。

两者可同时使用,优先验证 ETag,再比对 Last-Modified。

域名解析

通过DNS来进行网络分流

DNS解析的过程:

  1. 客户端先检查本地的DNS缓存,查看是否存在并且是存活着的该域名的地址记录。
  2. 客户端把地址发送给操作系统中配置的本地DNS(Local DNS),这个本地DNS地址可以由用户手工设置,也可以在DNCP分配时或者在拨号时从PPP服务器中自动获取。
  3. 本地DNS收到查询请求后,会按照“是否有www.icyfenix.com.cn的权威服务器”→“是否有icyfenix.com.cn的权威服务器”→“是否有com.cn的权威服务器”→“是否有cn的权威服务器”的顺序,依次查询自己的地址记录,如果都没有查询到,就会一直找到最后点号代表的根域名服务器为止。
  4. 假设本地DNS是全新的,按照步骤3的顺序查询到根服务器后,它将会得到“cn的权威服务器”的地址记录,然后通过“cn的权威服务器”,得到“com.cn的权威服务器”的地址记录,以此类推,最后找到能够解释www.icyfenix.com.cn的权威服务器地址。
  5. 通过“www.icyfenix.com.cn的权威服务器”,查询www.icyfenix.com.cn的地址记录,地址记录并不一定就是指 IP 地址,在 RFC 规范中有定义的地址记录类型已经多达数十种,譬如 IPv4 下的 IP 地址为 A 记录,IPv6 下的 AAAA 记录、主机别名 CNAME 记录,等等。
  • 权威域名服务器:负责翻译特定域名的DNS服务器。

  • 根域名服务器:固定的、无需查询的顶级域名服务器,全世界一共13组根域名服务器。

  • 通过NDS进行分流:

智能线路:根据访问者所处的不同地区(譬如华北、华南、东北)、不同服务商(譬如电信、联通、移动)等因素来确定返回最合适的 A 记录,将访问者路由到最合适的数据中心,达到智能加速的目的。

传输链路

优化流量传输的链路,从而达到减少请求的效果。

  • 前端优化方式(只是看看)

雅虎 YSlow-23 条规则

  • 连接数优化(优化TCP)

HTTP3.0之前的网络都是基于TCP实现的,3.0基于UDP实现。

目前大部分流量的特征如下:

数量多,时间短,资源小,切换快。

TCP协议必须在三次握手完成之后才能开始数据传输,可能会带来百毫秒的开销。

TCP还有慢启动的特性,使得刚刚建立连接时传输速度是最低的,后面在逐步加速直到稳定。

TCP慢启动:避免发送过多数据到网络中而导致网络阻塞。

  1. 长期持有连接(已过时):

    持久连接的原理是让客户端对同一个域名长期持有一个或多个不会用完即断的 TCP 连接。典型做法是在客户端维护一个 FIFO 队列,每次取完数据(如何在不断开连接下判断取完数据将会放到稍后传输压缩部分去讨论)之后一段时间内不自动断开连接,以便获取下一个资源时直接复用,避免创建 TCP 连接的成本。

可能会出现队首阻塞问题,如果队首的TCP连接需要传输的数据一直处于阻塞,导致后面的操作即使可以进行,也会被阻塞。

解决队首阻塞问题:让客户端一次将所有需要请求的资源全部发送给服务器,由服务器安排返回的顺序,管理传输队列。

  1. HTTP/2多路复用:

通过一个TCP连接,让所有的数据以帧的形式传递,通过每个帧附带的流id,把多个流的数据按照各自流组装区分开。这样可以做到只使用同一个TCP连接来传输多个不同的HTTP请求和响应报文。

img

有了HTTP2的多路复用后,客户端和每个域名只需要建立一个TCP连接,减轻了服务器的连接压力。

可靠传输机制:如果TCP传输过程中,如果有一个包出现错误,那所有的流都需要等待这个包重传成功后,才能组装。(为了避免因为少包导致组装出错误的数据) 上述情况在基于UDP的HTTP3中给出了解决方案

HTTP2和HTTP1对比,由于TCP自身的可靠传输机制,导致2并不是在所有方面的优于1。

HTTP1:每个TCP请求都是独立的连接,一个TCP同时只能处理一个请求,这样更适合传输大文件。(不需要在本地进行组装。) HTTP2:一个客户端与一个域名通常只有一个连接,由于多个请求共用同一个TCP连接,导致传输数据量变大,而且需要进行本地组合,如果中间某个包出现错误,那所有的请求都不能被处理。所以HTTP2更适合处理小文件传输

  • 传输压缩

通过压缩传输的数据大小实现连接优化的目的。

具体有静态预压缩和“即时压缩两种实现

  • 基于UDP的HTTP3.0(快速UDP网络连接)QUIC

QUIC:

1754981650147.png

QUIC与TLS1.2的TCP握手比较

当一个请求中某个包丢失了数据,由于UDP没有可靠传输机制,此连接依然可以给其他请求做处理,只有丢失包的请求有问题。即使一个请求发生了错误也不会影响到其他的请求。

1754981623098.png

三种HTTP协议的对比

QUIC在应用程序空间中实现的,而不在操作系统内核中实现。当数据在应用程序之间移动时,会存在上下文切换而带来额外开销。

内容分发网络(CDN)

更适合用来分发静态资源,动态资源反而不会有好效果

  • 一个互联网系统的速度取决于4点因素
  1. 网站服务器接入网络运营商的链路所能提供的出口带宽
  2. 客户端接入网络运营商的入口带宽。
  3. 从网站到用户之间经过不同运营商之间互联网节点的带宽。
  4. 从网站到用户之间的物理链路传输延迟。
  • 路由解析

1754984068873.png

1754984194582.png

DNS服务器给浏览器返回的ip为CDN服务器,CDN代理请求源服务器起到加速的效果。

如果CDN服务中直接有用户想要的资源,就直接返回。

  • 内容分发(CDN服务器中的资源同步问题)

资源同步主要有两种方式:

  1. 主动分发(push):源网站主动发起分发,把内容从源网站或者其他资源库推送到用户边缘的各个CDN缓存节点上。甚至可以直接主动推送到浏览器本地的localStorage中
  2. 被动回源(Pull):当某个资源首次被用户请求时,CDN服务器发现自己没有资源,就会实时从源网站中获取,然后缓存到CDN服务器本地。

如何判断CDN中缓存的内容是否失效

现在,最常见的做法是超时被动失效与手工主动失效相结合。超时失效是指给予缓存资源一定的生存期,超过了生存期就在下次请求时重新被动回源一次。而手工失效是指 CDN 服务商一般会提供给程序调用来失效缓存的接口,在网站更新时,由持续集成的流水线自动调用该接口来实现缓存更新,譬如“icyfenix.cn”就是依靠Travis-CI的持续集成服务来触发 CDN 失效和重新预热的。

负载均衡

以统一的接口对外提供服务,调度后方的多台机器。

  • OSI七层模型

表 4-1 OSI 七层模型

数据单元功能
7应用层 Application Layer数据 Data提供为应用软件提供服务的接口,用于与其他应用软件之间的通信。典型协议:HTTP、HTTPS、FTP、Telnet、SSH、SMTP、POP3 等
6表达层 Presentation Layer数据 Data把数据转换为能与接收者的系统格式兼容并适合传输的格式。
5会话层 Session Layer数据 Data负责在数据传输中设置和维护计算机网络中两台计算机之间的通信连接。
4传输层 Transport Layer数据段 Segments把传输表头加至数据以形成数据包。传输表头包含了所使用的协议等发送信息。典型协议:TCP、UDP、RDP、SCTP、FCP 等
3网络层 Network Layer数据包 Packets决定数据的传输路径选择和转发,将网络表头附加至数据段后以形成报文(即数据包)。典型协议:IPv4/IPv6、IGMP、ICMP、EGP、RIP 等
2数据链路层 Data Link Layer数据帧 Frame负责点对点的网络寻址、错误侦测和纠错。当表头和表尾被附加至数据包后,就形成数据帧(Frame)。典型协议:WiFi(802.11)、Ethernet(802.3)、PPP 等。
1物理层 Physical Layer比特流 Bit在物理网络上传送数据帧,它负责管理电脑通信设备和网络媒体之间的互通。包括了针脚、电压、线缆规范、集线器、中继器、网卡、主机接口卡等。
  • 数据链路层负载均衡

img

通过修改目标服务器的mac地址来实现负载均衡,无法跨子网,只能在有限的空间下做负载均衡。

  • 网络层负载均衡

1754985460755.png

  • 应用层实现的负载均衡(最常用的负载均衡手段之一)

img

  • 负载均衡策略和实现
  1. 轮循均衡(Round Robin):每一次来自网络的请求轮流分配给内部中的服务器,从 1 至 N 然后重新开始。此种均衡算法适合于集群中的所有服务器都有相同的软硬件配置并且平均服务请求相对均衡的情况。
  2. 权重轮循均衡(Weighted Round Robin):根据服务器的不同处理能力,给每个服务器分配不同的权值,使其能够接受相应权值数的服务请求。譬如:服务器 A 的权值被设计成 1,B 的权值是 3,C 的权值是 6,则服务器 A、B、C 将分别接收到 10%、30%、60%的服务请求。此种均衡算法能确保高性能的服务器得到更多的使用率,避免低性能的服务器负载过重。
  3. 随机均衡(Random):把来自客户端的请求随机分配给内部中的多个服务器,在数据足够大的场景下能达到相对均衡的分布。
  4. 权重随机均衡(Weighted Random):此种均衡算法类似于权重轮循算法,不过在分配处理请求时是个随机选择的过程。
  5. 一致性哈希均衡(Consistency Hash):根据请求中某一些数据(可以是 MAC、IP 地址,也可以是更上层协议中的某些参数信息)作为特征值来计算需要落在的节点上,算法一般会保证同一个特征值每次都一定落在相同的服务器上。一致性的意思是保证当服务集群某个真实服务器出现故障,只影响该服务器的哈希,而不会导致整个服务集群的哈希键值重新分布。
  6. 响应速度均衡(Response Time):负载均衡设备对内部各服务器发出一个探测请求(例如 Ping),然后根据内部中各服务器对探测请求的最快响应时间来决定哪一台服务器来响应客户端的服务请求。此种均衡算法能较好的反映服务器的当前运行状态,但这最快响应时间仅仅指的是负载均衡设备与服务器间的最快响应时间,而不是客户端与服务器间的最快响应时间。
  7. 最少连接数均衡(Least Connection):客户端的每一次请求服务在服务器停留的时间可能会有较大的差异,随着工作时间加长,如果采用简单的轮循或随机均衡算法,每一台服务器上的连接进程可能会产生极大的不平衡,并没有达到真正的负载均衡。最少连接数均衡算法对内部中需负载的每一台服务器都有一个数据记录,记录当前该服务器正在处理的连接数量,当有新的服务连接请求时,将把当前请求分配给连接数最少的服务器,使均衡更加符合实际情况,负载更加均衡。此种均衡策略适合长时处理的请求服务,如 FTP 传输。

服务器缓存(重点)

用空间换时间

  • 缓存用来做什么?
  1. 缓解CPU压力做缓存:(记忆化搜索)

    把方法运行的结果存储起来、把原本要实时计算的内容提前算好、让一些公共资源可以复用,这样可以节省CPU算力,顺带提升响应性能。

  2. 缓解IO压力而做缓存:

    把对网络、磁盘等较慢介质的读写访问变为对内存等较快介质的访问,将原本对单点部件(如数据库)的读写变为到可伸缩部件(如缓存中间件)的访问,顺便提升响应性能。

缓存属性

缓存主要分四个维度

  • 吞吐量:缓存的吞吐量使用OPS值(每秒操作数)来衡量,反映了对缓存进行并发读写操作的效率,即缓存本身的工作效率高低。

常见的进程内缓存吞吐量

img

环形缓存(直接内存等)

它是一种拥有读、写两个指针的数据复用结构。

img

  • 命中率与淘汰策略:成功从缓存中返回结果次数与总请求次数的比值,反映了引入缓存的价值高低,命中率越低,收益越小,价值越低。

任何缓存的容量都不可能是无限的,命中率是为了考虑空间消耗和节约时间之间取平衡。

基本的三种淘汰策略:

  1. FIFO(First In First Out):先入先出,最先入缓的数据最先被淘汰,可以通过一个固定容量限制的队列来实现。

    常被用到数据,越是有可能最早的进入缓存,如果使用特别频繁,可能会大幅度降低缓存的命中率。

  2. LRU(Least Recent Used):优先淘汰最久未被访问过的数据。LRU通常采用HashMap 加 LinkedList 双重结构(如 LinkedHashMap)来实现,HashMap提供数据访问,LinkedList实现动态修改节点。

    如果某个热点数据,由于一些特殊原因一段时间内没有被访问,可能会导致热点数据被错误的淘汰。

  3. LFU(Least Frequently Used):优先淘汰最不经常被使用的数据。LFU给每个数据添加一个访问计数器,每访问一次就加1,需要淘汰时就清理计数器数值最小的那批数据。(有点类似于JVM的分代升级机制)

    可以解决上面提到的特殊原因热点数据没有被访问问题,但同时也引入了新的问题:1、每个缓存的数据都需要维护一个计数器。2、如果某个极其经常被访问的数据失效了。通过LFU也无法被清理。

  4. TinyLFU(Tiny Least Frequently Used):使用少量的样本数据来评估整体的情况,将缓存的数据分类,通过维护某类数据的访问次数,来评估这些数据的淘汰与否。(常见的是可以用布隆过滤器来实现等价的分类)

    为了解决极其热点数据过期的问题,采用滑动窗口来统计一部分数据的使用计数情况。

    但如果访问热点数据非常稀疏,也就是说分类无效的时候,反而不知道该如何清理了。

  5. W-TinyLFU(Windows-TinyLFU):同样采用TinyLFU的缓存机制,但是分类后,局部采用LRU的形式来淘汰数据,通过LFU来确定哪些块需要被淘汰,然后通过LRU只淘汰需要被淘汰的数据的一部分。

几种缓存命中率的情况

1754989995230.png

  • 扩展功能:除了基本的读写功能外,还提供了哪些额外的管理功能,如最大容量、失效时间、失效事件、命中率统计等。
  1. 加载器:许多缓存都有“CacheLoader”之类的设计,加载器可以让缓存从只能被动存储外部放入的数据,变为能够主动通过加载器去加载指定 Key 值的数据,加载器也是实现自动刷新功能的基础前提。
  2. 淘汰策略:有的缓存淘汰策略是固定的,也有一些缓存能够支持用户自己根据需要选择不同的淘汰策略。
  3. 失效策略:要求缓存的数据在一定时间后自动失效(移除出缓存)或者自动刷新(使用加载器重新加载)。
  4. 事件通知:缓存可能会提供一些事件监听器,让你在数据状态变动(如失效、刷新、移除)时进行一些额外操作。有的缓存还提供了对缓存数据本身的监视能力(Watch 功能)。
  5. 并发级别:对于通过分段加锁来实现的缓存(以 Guava Cache 为代表),往往会提供并发级别的设置。可以简单将其理解为缓存内部是使用多个 Map 来分段存储数据的,并发级别就用于计算出使用 Map 的数量。如果将这个参数设置过大,会引入更多的 Map,需要额外维护这些 Map 而导致更大的时间和空间上的开销;如果设置过小,又会导致在访问时产生线程阻塞,因为多个线程更新同一个 ConcurrentMap 的同一个值时会产生锁竞争。
  6. 容量控制:缓存通常都支持指定初始容量和最大容量,初始容量目的是减少扩容频率,这与 Map 接口本身的初始容量含义是一致的。最大容量类似于控制 Java 堆的-Xmx 参数,当缓存接近最大容量时,会自动清理掉低价值的数据。
  7. 引用方式:支持将数据设置为软引用或者弱引用,提供引用方式的设置是为了将缓存与 Java 虚拟机的垃圾收集机制联系起来。
  8. 统计信息:提供诸如缓存命中率、平均加载时间、自动回收计数等统计。
  9. 持久化:支持将缓存的内容存储到数据库或者磁盘中,进程内缓存提供持久化功能的作用不是太大,但分布式缓存大多都会考虑提供持久化功能。
  • 分布式支持:缓存由外部提供,访问缓存需要通过网络,缓存中的数据可以在各个服务节点中共享。

分布式缓存

缓存的读取

  1. 复制式缓存:能够支持分布式的进程内缓存,(如tomcat的全量复制模式,将tomcat中的Session信息全部复制一遍,从而达到session共享的目的)。读取数据时无需网络访问,直接从进程内存中返回,性能极高。当数据发生变化时,必须遵守复制协议,将变更同步给集群内的每个节点中。

    读取性能极高,但修改代价过于高昂。

  2. 集中式缓存:(redis),读写都需要网络访问,不会随着集群节点的增加而产生额外的负担,但读写的性能无法到达进程内部缓存的高性能。

数据一致性

这部分其实就是之前事务中提到的CAP理论,都只能尽量的达到最终一致性,不能做到单机ACID的强一致性。

redis集群就是典型的AP式,性能高,但不保证强一致性。

可以保证强一致性的有:ZooKeeper、Doozerd、Etcd等框架。 虽然可以保证强一致性,但吞吐量等和redis根本不是一个量级。

Q:既然有了分布式缓存,为什么还要引入二级缓存?

A:分布式缓存每次访问都需要网络通讯,引入分布式加本地的二级缓存机制,只要缓存不失效,从第二次其的所有读取都不再需要网络请求,大大的提高了读取性能。

1754995844029.png

Q:如何保证二级缓存的一致性?

A:必须实现缓存透明,变更以分布式缓存中的数据为准,访问(读取)以进程内的数据优先。如果数据发生变动时,在集群内发生通知(通知的方式:redis的PUB/SUB

,或者Zookeeper或Etcd来处理),让各个节点的一级缓存中对应的数据自动失效。当访问缓存时,统一查询一、二级缓存联合查询,自动更新一级缓存。接口外部是只查询一次,接口内部自动实现优先查询一级缓存,未获取到数据再自动查询二级缓存的逻辑。

缓存风险

  • 缓存穿透

总是查询缓存中没有数据,达到跳过缓存访问数据库的情况。(如查数据库中根本不存在的数据)

解决方案:

  1. 业务逻辑根本就不能避免的缓存穿透,可以约定一定时间内对返回为空的Key值依然进行缓存,使得一段时间内缓存不会被一个key而多次穿透。

    效果并不好,给缓存带来了更大的压力,且对恶意攻击的防御效果并不好。(我可以随机生成key来恶意访问,或查询一些根本不存的日期的数据,如1800年的数据)

  2. 防止恶意攻击,可以使用布隆过滤器来解决。如果布隆过滤器中不存在的key,就直接返回结果。

    成本为需要在本地维护一个布隆过滤器,布隆过滤器对有效值的一致性不需要特别的实时,但对于空值需要较高的一致性,不能出现缓存中有,布隆过滤器中没有的情况。

  • 缓存击穿

某个热key突然失效了,导致大量的请求被打到数据库。

  1. 加锁同步,对请求该数据的key值加锁,使只有第一个请求会进入数据库,其他线程阻塞或者重试。

    实现复杂

  2. 热点数据由代码来手动管理,对于热点数据,有代码来有计划的完成更新、失效,避免由同步策略来管理。

    这里有个思路,感觉可以模仿mysql的redo来动态实现缓存的更新和失效。修改的数据先不同步数据库,通过日志来定期刷库。

  • 缓存雪崩

大量的热点数据短时间内一起失效。(热点数据同时创建,且超时时间一样。)短时间给数据库带来了极大的压力。

出现上面情况的原因为大量的热点数据被同时加载预热:

  1. 提升缓存系统的可用性,建立分布式缓存的集群。如主节点数据失效,去从节点看看是否还在。
  2. 透明的多级缓存,每个服务的一级缓存中的数据都有不一样的加载时间,分散了过期时间。
  3. 同时被预热的缓存数据,把过期时间设置成一段时间内的随机数,负载均衡了过期时间,避免大规模同时失效。
  • 缓存污染

缓存中的数据与数据库中的数据不一致。(最终一直性没有得到保证)

产生原因:

数据库中的数据发生回滚(缓存中存放了回滚前的数据)

写入缓存失败(缓存中为旧数据)

解决方案:

解决方案多种多样,这里只列出redis常用的Cache Aside 方式,后续讨论分布式一致性时详细介绍。

  • 读数据时,先读缓存,缓存没有的话,再读数据源,然后将数据放入缓存,再响应请求。
  • 写数据时,先写数据源,然后失效(而不是更新)掉缓存。

为什么不使用延时双删?

延时双删逻辑为过一段时候后再删除一次数据,时间在分布式系统中并不可靠,先发未必先至,后发未必后至。不如直接让缓存失效,把数据一致性问题交给缓存和数据库同步。

依然有问题:如果写操作在查询操作之后,查询到的数据可能为过期的数据。

架构安全性

认证

如何正确的分辨出操作用户的真实身份

其实登录就是一个简单的认证操作。

  • HTTP认证

所有支持HTTP协议的服务器,在未授权的用户意图访问服务端保护区域资源时,应返回 401 Unauthorized 的状态码。

1755002396893.png

只面向传输的协议进行认证,与内容无关

  • Web认证(表单认证)

身份认证由应用程序本身的功能完成,而不是由HTTP服务器来负责认证。依靠内容而不是传输协议来实现的认证。

WebAuthn 规范涵盖了“注册”与“认证”两大流程,先来介绍注册流程,它大致可以分为以下步骤:

  • WebAuthn的注册:
  1. 用户进入系统的注册页面,这个页面的格式、内容和用户注册时需要填写的信息均不包含在 WebAuthn 标准的定义范围内。
  2. 当用户填写完信息,点击“提交注册信息”的按钮后,服务端先暂存用户提交的数据,生成一个随机字符串(规范中称为 Challenge)和用户的 UserID(在规范中称作凭证 ID),返回给客户端。
  3. 客户端的 WebAuthn API 接收到 Challenge 和 UserID,把这些信息发送给验证器(Authenticator),验证器可理解为用户设备上 TouchID、FaceID、实体密钥等认证设备的统一接口。
  4. 验证器提示用户进行验证,如果支持多种认证设备,还会提示用户选择一个想要使用的设备。验证的结果是生成一个密钥对(公钥和私钥),由验证器存储私钥、用户信息以及当前的域名。然后使用私钥对 Challenge 进行签名,并将签名结果、UserID 和公钥一起返回客户端。
  5. 浏览器将验证器返回的结果转发给服务器。
  6. 服务器核验信息,检查 UserID 与之前发送的是否一致,并用公钥解密后得到的结果与之前发送的 Challenge 相比较,一致即表明注册通过,由服务端存储该 UserID 对应的公钥。

1755002701944.png

  • WebAuthn的认证
  1. 用户访问登录页面,填入用户名后即可点击登录按钮。
  2. 服务器返回随机字符串 Challenge、用户 UserID。
  3. 浏览器将 Challenge 和 UserID 转发给验证器。
  4. 验证器提示用户进行认证操作。由于在注册阶段验证器已经存储了该域名的私钥和用户信息,所以如果域名和用户都相同的话,就不需要生成密钥对了,直接以存储的私钥加密 Challenge,然后返回给浏览器。
  5. 服务端接收到浏览器转发来的被私钥加密的 Challenge,以此前注册时存储的公钥进行解密,如果解密成功则宣告登录成功。

授权

如何控制一个用户该看到哪些数据、能操作哪些数据

授权主要包括两个部分:

  1. 确保授权过程可靠:让第三方系统能够访问的所需的资源,又能保证其不泄露用户敏感数据。(OAuth2和SAML2.0)
  2. 确保授权的结果可控:对应用程序功能或者资源的访问控制,具体分几种:自主访问控制,强制访问控制,基于属性的访问控制,基于角色访问控制(常用)
  • RBAC(基于角色的权限控制)

所有的访问控制模型,实质上都是在解决同一个问题:“(User)拥有什么权限(Authority)去操作(Operation)哪些资源(Resource)”。

1755052415482.png

1755052991246.png

为了避免给每个用户设定权限,将权限从用户身上剥离,改为绑定到角色上。

将权限控制变为对“角色拥有操作哪些资源许可”。

  • OAuth2

国际标准,面向于解决第三方应用的认证授权协议。授权以令牌的形式实现,令牌难以主动失效。

直接把账号和密码交给第三方会导致如下问题:

  1. 密码泄露,如果第三方被攻击,用户的密码就会泄露。
  2. 访问范围:第三方有能力访问所有的用户数据,等同于拥有用户相同的权限。
  3. 授权回收:只能通过用户自己修改密码来收回授权,同时如果用户修改了密码,那之前所有的授权都会失效。

1755054244022.png

OAuth2主要通过令牌的方式来给第三方授权。

  • OAuth2的四种授权方式,安全等级逐级下降
  1. 授权码模式(最严谨):

    1755054423220.png

    1. 会不会有其他应用冒充第三方应用骗取授权? ClientID 代表一个第三方应用的“用户名”,这项信息是可以完全公开的。但 ClientSecret 应当只有应用自己才知道,这个代表了第三方应用的“密码”。在第 5 步发放令牌时,调用者必须能够提供 ClientSecret 才能成功完成。只要第三方应用妥善保管好 ClientSecret,就没有人能够冒充它。
    2. 为什么要先发放授权码,再用授权码换令牌? 这是因为客户端转向(通常就是一次 HTTP 302 重定向)对于用户是可见的,换而言之,授权码可能会暴露给用户以及用户机器上的其他程序,但由于用户并没有 ClientSecret,光有授权码也是无法换取到令牌的,所以避免了令牌在传输转向过程中被泄漏的风险。
    3. 为什么要设计一个时限较长的刷新令牌和时限较短的访问令牌?不能直接把访问令牌的时间调长吗? 这是为了缓解 OAuth2 在实际应用中的一个主要缺陷,通常访问令牌一旦发放,除非超过了令牌中的有效期,否则很难(需要付出较大代价)有其他方式让它失效,所以访问令牌的时效性一般设计的比较短,譬如几个小时,如果还需要继续用,那就定期用刷新令牌去更新,授权服务器就可以在更新过程中决定是否还要继续给予授权。至于为什么说很难让它失效,我们将放到下一节“凭证”中去解释。
  2. 隐式授权:

    1755054655827.png

    授权服务器不验证第三方应用的身份。

  3. 密码凭证:类似于之前的直接提供密码

    1755054833748.png

  4. 客户端模式:直接由“第三方”和授权服务器交互,中间没有资源所有者参与。

    1755054963728.png

凭证

用户和系统之间确定操作的意图都是真实的,准确的、完整且不可抵赖的。

单机应用时代,凭证主要由Cookie-Session实现,Session的服务器内部单机维护保证本次授权或状态可以得以正确的保持和销毁。

分布式时代由于有CAP不可兼容原理的限制,导致不方便在单机上来维护状态和让状态销毁,于是采用JWT令牌的方式来实现。

  • Cookie-Session

HTTP协议是无状态的协议,每次对事务处理没有上下文的记忆能力,每个请求都是相互独立的。

Session的实现

Session是在Http协议中增加了Set-Cookie 指令,用户在收到Cookie后,后面一段时间内的每次 HTTP 请求中,以名为 Cookie 的 Header 附带着重新发回给服务端。

服务器根据Header中的cookie分辨出请求来自哪个用户。

安全

状态信息都存储于服务器,只要依靠客户端的同源策略和 HTTPS 的传输层安全,保证 Cookie 中的键值不被窃取而出现被冒认身份的情况,就能完全规避掉上下文信息在传输过程中被泄漏和篡改的风险。

主动下线

Cookie-Session 方案的另一大优点是服务端有主动的状态管理能力,可根据自己的意愿随时修改、清除任意上下文信息,譬如很轻易就能实现强制某用户下线的这样功能。

Session共享(CAP不可兼得):

  1. 牺牲一致性:利用负载均衡算法,让固定用户的请求只会访问到固定的服务器
  2. 牺牲可用性:每个节点都复制一份相同的Session,如果一个节点发生变动,通知所有服务都修改。
  3. 牺牲分区容错性:把Session集中保存在一个所有节点都可以访问的集中存储(Redis),一旦存取Session的节点损坏或出现网络分区,整个集群就不能提供服务了。
  • JWT

把状态信息存储在客户端,每次请求都携带回服务器。

img

JWT大致分三部分:

  1. 令牌头:描述令牌的类型和使用的签名算法

  2. 负载:令牌真正向服务器传输的消息

  3. 签名(一种哈希算法):对象头中公开的签名算法,通过服务器特定的秘钥对前面两个部分加密,而来。

    签名为了确保负载中的信息是可信的、没有被篡改的,也没有在传输过程中丢失任何信息。

JWT的缺点:

如何主动让令牌失效

JWT令牌一旦签发,理论上就和认证服务没有瓜葛了,到期之前始终有效。如果想要实现主动令牌失效,需服务端对需要失效的令牌加入到黑名单中,统一存到redis中。如果检测到黑名单令牌,就让他逻辑失效。

容易遭受重放攻击

一旦请求被劫持,虽然因为签名无法修改内容,但是可以一直主动攻击服务器。

真要处理重放攻击,建议的解决方案是在信道层次(譬如启用 HTTPS)上解决,而不提倡在服务层次(譬如在令牌或接口其他参数上增加额外逻辑)上解决。

只能携带有限的数据

由于请求在头中,HTTP本身没有约束header 的最大长度,但tomcat默认最大8kb,Nginx默认4kb。都决定了JWT令牌无法携带大量数据。

必须考虑令牌在客户端如何存储”:

一旦JWT被泄露,就可以做任何事情。必须要保存的客户端考虑安全问题。

保密

如何保证数据无法被内外人员窃取、滥用。通过加密改变原有数据,即使未授权的用户获取了已加密的信息,因不知道如何解密,而无法了解真实内容。

传输

保证网络中额信息王法被窃听、篡改和冒充

  • 公钥加密,私钥解密:加密,用来给数据加密。
  • 私钥加密,公钥解密:签名,用来让公钥所有者验证私钥所有者的身份,防止私钥所有者发布的内容被篡改。(但内容可能会被获取)

表 5-1 三种密码学算法的对比

类型特点常见实现主要用途主要局限
哈希摘要不可逆,即不能解密,所以并不是加密算法,只是一些场景把它当作加密算法使用。 易变性,输入发生 1 Bit 变动,就可能导致输出结果 50%的内容发生改变。 无论输入长度多少,输出长度固定(2 的 N 次幂)。MD2/4/5/6、SHA0/1/256/512摘要无法解密
对称加密加密是指加密和解密是一样的密钥。 设计难度相对较小,执行速度相对较块。 加密明文长度不受限制。DES、AES、RC4、IDEA加密要解决如何把密钥安全地传递给解密者。
非对称加密加密和解密使用的是不同的密钥。 明文长度不能超过公钥长度。RSA、BCDSA、ElGamal签名、传递密钥性能与加密明文长度受限。
  • 传输安全层TlS协议

1755067065735.png

  1. 客户端请求:

    客户端向服务器请求进行加密通讯,本次请求是明文的。并携带加密,混淆机制,协议版本等信息。和一个随机数

  2. 服务器回应:

    服务器收到请求后,回复客户端。并携带随机数,协议版本,加密信息等。

  3. 客户端确认:

    客户端利用RSA等算法,生成一个新的随机数。通过三个随机数一起组成对称加密的私钥,后续对称加密都采用此秘钥。

  4. 服务端确认:

    服务器握手结束通知

TLS握手结束后,双方只使用随机生成的秘钥对称加密来处理请求。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计