高性能架构之道, 分布式、并发编程、数据库调优、缓存设计、IO模型、前端优化、高可用
第1章 高性能架构 001
1.1 软件架构 001
- 理念层面:如研究软件的开发模型、评价指标、架构风格等。
- 架构层面:研究如何协调和组织软件系统、子系统、模块之间的关系。类比于规划和设计建筑物的承重结构、功能结构等,并协调各结构的关系。
- 技术层面:研究如何高效、可靠、经济地实现软件系统、子系统、模块等。类比于搭建建筑物中的楼梯、墙体、阳台等。
1.2 软件的质量 003
1.3 高性能概述 004
效率分为3个子特性:
- 时间效率(Time Behaviour)
- 资源利用率(Resource Utilization)
- 容量(Capacity):存储的项目数、并发用户数、通信带宽、事务吞吐量和数据库大小。
可靠性分为4个子特性:
- 成熟度(Maturity)
- 可用性(Availability)
- 容错性(Fault Tolerance)
- 可恢复性(Recoverability)
1.4 软件性能指标 005
1.4.1 吞吐量 006
- TPS (Transaction Per Second)即每秒进行的事务的数目。这里的事务一般是指某项具体的包含请求、变更、返回等全流程的操作。
- QPS (Queries Per Second)即每秒进行的查询操作数目。
1.4.2 并发数 006
1.4.3 平均响应时间 007
用户作为个个体,并不知道他所访问的系统的吞吐量的高低,也不知道此时系统的并发数是多少。用户能够感受到的是另一个指标——响应时间。
阿姆达尔定律描述了当系统中某一模块的执行速度提升时, 系统整体执行速度提升的情况。
阿姆达尔定律首先定义了加速比的概念。假设我们优化了某个模块m,使之平均响应时间由$T_{m,old}$缩短为$T_{m,new}$,则这次优化带来的加速比$r_{m}$计算如下:
$$r_{m} = \frac{T_{m,old}}{T_{m,new}} $$
而因为模块的优化给整个系统带来的加速比$r_{s}$计算如下:
$$r_{s} = \frac{T_{s,old}}{T_{m,new}} = \frac{1}{[(1-p)+p/r_{m}]} $$
根据上述公式,我们知道要想提高系统的加速比,应该重点关注平均响应时间占比高的模块(增大P),并尽可能地提升这些重点模块的加速比(增大$r_{m}$ )。
1.4.4 可靠性指标 008
1.5 性能指标之间的关系 009
1.5.1 并发数对吞吐量的影响 009
1.5.2 并发数对平均响应时间的影响 011
1.5.3 平均响应时间对并发数的影响 012
1.5.4 可靠性指标与其他指标的关系 013
1.6 高性能架构总结 013
第2章 分流设计 014
2.1 内容分发网络 014
在实际生产中,会将图片、视频、附件等对流量消耗大且不经常改变的静态资源部署到网络的多个位置,而核心系统只部署在一个位置。这就构成了我们常见的内容分发网络(Content Delivery Network, CDN)
2.1.1 内容分发网络的结构 015
当用户请求边缘节点的内容时,边缘节点会判断自身是否缓存了用户要请求的内容。如果该内容已经缓存,则边缘节点直接将内容返给用户:如果该内容尚未缓存,边缘节点会去源站请求内容,再发给用户,并且还会根据设置决定自身是否将该内容缓存一份。因此,边缘节点中存储的只是源站中部分内容的备份。
但是CDN节点中只能缓存静态内容,一些涉及动态内容的请求仍然需要源站处理。
2.1.2 内容分发网络的原理 017
调用方请求一个服务时给出的目的地址是源站的地址,它对应了网络中源站所在的节点。内容分发网络如何将指向源站地址的请求分散到不同的CDN节点上呢?
这一过程与域名解析有关。域名解析就是将域名解析为IP地址的过程。
DND存储的记录类型:
- A记录:这是最常用的记录类型,它记录了域名和IP地址的对应关系。通过它可以将一个域名转为IP地址,如将"yeecode.top" 转为“185.199.108.153"
- CNAME记录:它也被称为别名记录,它记录了域名和域名的对应关系。通过它我们可以将一个域名转为另一个域名,如将“yeecode.github.io"转为“yeecode.top”
处在最顶端的是根DNS服务器,在世界上一共有13台。
域名解析是一个递归查找的过程。
CDN服务商的DNS服务器并不会简单地给出个固定的IP地址,而是根据用户请求的源IP等信息,寻找出一个距离当前用户最近的CDN节点的IP后返回给用户。这样,用户解析源站的域名,拿到的却是CDN节点的地址。
2.2 多地址直连 019
我们可以把内容分发网络的工作原理简化为地址获取和内容请求两大步。
- 用户向服务注册中心获取能提供某项服务的系统的具体地址。在这一步,服务注册中心可以为不同的用户提供不同的地址。
- 用户前往提供服务的具体地址获取内容。
2.3 反向代理 021
第3章 服务并行设计 026
3.1 并行与并发 026
- 并行(parallesim) 是指在同一时刻有多个任务同时进行。例如,你在家中一边读书一边听歌。
- 并发(concurency)是指多个任务中的每个任务都被拆分成细小的任务片,从属于不同任务的任务片被轮番处理。因此,任意时刻都只有一个任务在进行。 但是从宏观上看,这些任务像是被同时处理的。
不过要注意的是,并发和并行的区分仅限于微观。从宏观上看,并发或并行的任务都像是同时开展的。
3.2 集群系统 028
3.2.1 无状态的节点集群 028
3.2.2 单一服务节点集群 029
在聊天系统中,用户之前的对话( 是通过过去的请求实现的)便是上下文:在游戏系统中,用户之前购买的装备、晋升的等级(也是通过过去的请求实现的)便是该用户的上下文。
要想让一个系统是有状态的,则必须要在处理用户的每个请求时能读取和修改用户的上下文信息。这在单一节点的系统中是容易实现的,只要将每个用户的信息都保存在这个节点上即可。而在节点集群中,这一切就变得复杂起来。其中一个最简单的办法是在节点和用户之间建立对应关系。
- 任意用户都有一个对应的节点,该节点上保存有该用户的上下文信息。
- 用户的请求总是落在与之对应的节点上的。
单一服务节点集群方案能够解决有状态服务的问题。但因为各个节点之间是隔离的,无法互相备份,当某个服务节点崩溃时,将会使该节点对应的用户失去服务。因此,这种设计方案的容错性比较差。
3.2.3 信息共享的节点集群 031
有一种方案可以解决有状态服务问题,并且不会因为某个服务节点崩溃而造成某些用户失去服务,那就是信息共享的节点集群。
数据库常作为信息池使用。当任何一个节点接收到用户请求时,都从数据库中读取该用户的上下文信息,然后根据用户请求进行处理。
在这种集群中,节点之间的信息是互通的,因此可以使用分布式锁解决并发唤醒等节点间的协作问题。种简 单的做法是在定时任务被触发时, 每个节点都向信息池中以同样的键写入一个不允许覆盖的数据。最终肯定只有一个节点能够写入成功,这个写入成功的节点获得执行定时任务的权限。
但因为多个节点共享信息池,受到信息池容量、读写性能的影响,系统在数据存储容量、数据吞吐能力等方面的提升并不明显。
3.2.4 信息一致的节点集群 032
我们可以让每个节点独立拥有自身的信息池。为了维继续保证系统提供有状态的服务,我们必须确保各个信息池中的数据信息是一致的。
这种信息一致的节点集群通常也会被称为分布式系统,但从严格意义上讲,它仍然是集群。因为分布式系统中的节点是异构的,不同的节点可能从属系统中的不同模块。而这里的节点是同构的,它们的出现是为了分担高并发数带来的压力。但是,信息一致的节点集群也需要面对分布式系统中经常面对的问题——分布式一致性问题。
要实现线性致性, 我们可以使用两阶段提交算法、三阶段提交算法等,这些算法的实施会对各个节点的吞吐量造成较大影响:要实现最终一致性,我们可以使用具有重试功能的异步消息中心等,这种方式对节点的吞吐量影响较小,但是集群可能会出现读写不一致的情况。
3.3 分布式系统 034
集群系统中,各个节点是同质的,各自运行一套完整且相同的应用程序。 如果应用程序比较复杂,其性能会受到硬件资源的制约。
当这样的应用运行在物理节点上时,便会因为CPU资源、内存资源、IO资源等的不足导致性能降低。这种性能的降低不是由并发数高引发的,而是由系统自身的复杂性引发的。
单体应用也带来了开发维护、可靠性方面的问题。
- 业务逻辑复杂
- 变更维护复杂:应用中任何一个微小的变动与升级都必须重新部署整个系统,随之而来的还有各种全量测试、回归测试等工作。
- 难以分拆升级
- 可靠性变差:任何一个功能模块的异常都可能导致应用的宕机。
我们可以将单体应用拆分成多个子应用,变成了分布式应用。
3.4 微服务系统 036
第4章 运算并发 038
4.1 多进程 038
那多个进程之间的运算可能是并行的也可能是并发的。这取决于运行程序的CPU的核数。如果CPU是单核的,则一定是并发的;如果CPU是多核的,则在CPU的调度下可能并行也可能并发。
进程是资源分配的最小单位,拥有独立地址空间,因此进程之间的切换需要对地址
空间进行切换。进程间切换的开销很大。在Linux中,当进行进程切换时,系统会先进入内核态,并在内核态完成地址空间的切换,寄存器、程序计数器、线程栈的切换等工作,然后再切换回用户态。
也正因为每个进程的资源独立,进程之间存在很强的隔离性。当一个进程因为资源
耗尽等原因崩溃时,不会影响其他进程。
多进程应用中各个进程之间的通信比较复杂,因此较难实现进程之间的协作。
4.2 多线程 039
每个进程内都有一个或多个线程。进程内的线程间共享内存,因此线程之间的切换
效率更高。
4.2.1 线程的状态及转换 039
4.2.2 多线程的应用场景 041
使用多线程的目的显然是为了并发,但从应用场景上区分主要可以分为两类:一类
是通过并发提升效率:另-类是通过并发实现异步操作。
则日志记录操作可以在一个新的线程中展开,它的时效性,甚至成功失败与否对主线程都不会造成影响。
4.2.3 多线程的创建 042
1.继承Thread类
NewThread 类继承了Thread类,并重写了run方法。在run方法中可以写入该线程需要执行的工作。
2.基于Runnable接口
继承Thread 类确实可以实现多线程,但是不建议这样使用,因为这种使用方法中
Thread的职责不明晰。
3.基于Callable接口
主线程在触发子线程之后,还需要获得各个子线程的执行结果。这时,基于Runnable接口的实现方式便无法满足要求,而要基于Callable接口实现。
4.2.4 线程池 046
4.2.5 多线程资源协作 050
4.2.6 多线程进度协作 056
4.3 多协程 069
4.4 运算并发总结 072
第5章 输入输出设计 074
5.1 概念梳理 074
5.1.1 同步与异步 074
同步与异步指的是调用方和被调用方之间的消息通信机制。如果调用方调用某个操
作,直到操作结束时调用方才能获得一个包含结果的回答,那么这个操作就是同步的。如果调用方调用某个操作,被调用方立刻给出一个不包含结果的回应,然后等被调用方得到结果时再主动通知调用方,那么这个操作就是异步的。
[!NOTE]
例如,我们到餐馆就餐时询问服务员是否可以就餐。如果服务员听到后不理我们,直到出现空位时才回答道“您现在可以就餐了”,这个等位服务就是同步的。如果服务员听到后立刻回应我们说“等下有位置时通知您",然后等出现位置时主动通知我们,这个等位服务就是异步的。
5.1.2 阻塞与非阻塞 075
阻塞与非阻塞的区别在于调用方在调用操作之后、得到回应或回答之前所处的状态。如果调用方调用操作后、得到回应或回答之前被挂起,那么调用方调用的这个操作就是阻寨的。如果调用方调用操作后、得到回应或回答之前是活跃的,那么调用方调用的这个操作就是非阻塞的。
[!NOTE]
例如,我们向服务员询问是否可以立刻就餐之后,立刻陷入昏睡状态,直到服务员回应或回答后才能再度清醒过来,那么这个等位操作就是阻塞的。如果我们询问之后,在服务员回应或回答之前,我们可以四处走动、打电话及做些其他事情, 那么这个等位操作就是非阻塞的。
同步与异步、阻塞与非阻塞这两类定义的划分维度不同。同步与异步关注的是消息
通信机制,而阻塞与非阻塞关注的是调用方的状态。
对于异步操作而言,讨论阻塞和非阻塞则没有太大意义。因为异步操作的请求会被
立刻回应,只是这个回应只代表请求被接收而不包含操作的结果。当我们提及“阻塞式IO”时便是指“同步阻塞式IO”,当我们提及“非阻塞式IO”时就是指“同步非阻塞式IO”。
5.2 IO模型 077
在我们平时接触的软硬件系统中,不再是面向接口编程,而是面向缓存编程。当我
们发送数据时,只需要把数据写入到输出缓存中即可,底层软硬件会帮助我们将缓存中的数据通过接口发送出去:当我们接收数据时,只需要从输入缓存读取数据即可,而不需要直接操作接口数据传输到缓存的这一过程。
5.3 IO模型的层级关系 078
5.4 阻塞式IO模型 079
阻塞式IO (Blocking IO, BIO) 是最为常见的IO模型,它是同步的、阻塞的。
例如,UNIX的所有套接字在默认情况下是阻塞的。
5.5 非阻塞式IO模型 081
非阻塞式IO (Non-blocking IO, NIO)模型在接收阶段是非阻塞的。调用方发起IO
操作时,无论接收阶段是否完成,IO操作会立刻给出个回应, 而不是将调用方挂起。这样,调用方就可以通过不断地轮询来判断接收过程是否完成,并且可以在轮询的间隙展开其他操作。
5.6 信号驱动式IO模型 082
5.7 复用式IO模型 083
但是,如果监听操作每次可以监听多个而不是一个I0操作时,上述改进则变得很有意义。调用方可以将多个I0操作委托给一个监听函数,然后调用方线程被阻塞。当多个IO操作中有一个或多个接收阶段完成时,调用方线程便被唤醒。这时,调用方可以直接操作接收阶段已完成的IO,这就是复用式IO模型。
5.8 异步式IO模型 086
5.9 输入输出模型总结 088
第6章 数据库设计与优化 090
6.1 数据库设计概述 090
6.2 关系型数据库设计 091
在软件开发过程中,通常使用的是面向对象的编程,面向对象是从软件工程原则(如聚合、封装)的基础上发展而来的。传统数据库是指关系型数据库,它是从数学理论(集合代数等)的基础上发展而来的。因此,面向对象和关系型数据库来自不同的理论,两者不完全匹配,它们之间存在一一个转化过程,被称为对象一关系映射。
对象:
关系:
Student表
id | name |
---|---|
6.2.1 设计范式介绍 093
- 多值依赖:假设关系数据库中有三个独立属性X、Y、Z,如果选中某个值x,则总会对应着值y,而不论z的任何取值,那么就说存在多值依赖。
示例表是一个用于存储学生信息的表, 其相关属性如下所示。
Table 1:
学号 | 姓名 | 班级编号 | 学生在班级内的序号 | 性别 | 班级人数 | 年龄 | 是否成年 | 紧急联系手机号 |
---|---|---|---|---|---|---|---|---|
存在如下合理假设:
- 学生可能会有重名。
- 学生在班级内的序号由姓名首字母排序得来。
- 一个同学可能有多个紧急联系手机号,一个紧急联系手机号也可能关联多个同学。
5.第四范式
第四范式(4NF)要求属性之间不允许有非平凡,且非函数依赖的多值依赖。
因为一个学生可能有多个紧急联系手机号, 因此Table 1不满足第四范式。
Table 1:
学号|学生姓|学生名|班级编号|性别|年龄
Table 2:
班级编号|班主任
Table 3:
年龄|是否成年
Table 4:
学号|学生在班级内的序号
Table 5:
学号1紧急联系手机号
6.第五范式
第五范式(5NF)要求表中的每个连接依赖由且仅由候选键推出。
6.2.2 反范式设计 101
反范式设计就是在范式设计的基础上,违反范式中的某一条或某几条,以达到提升系统查询效率等效果。我们在使用时,要确保范式设计是反范式设计的基础,在范式设计的基础上根据目的进行特定的违反操作,切不可将反范式设计当作随意设计的理由。
6.3 索引原理与优化 102
6.3.1 索引的原理 103
1.Hash索引
Hash索引是利用Hash函数来对数据表中的数据增加索引。当我们对数据表中的某一列增加索引时, 数据库会将该列的所有数据的Hash结果计算出来,并将Hash结果和该记录的地址存放到索引文件中。
3.位图索引
则对“性别”“角色”“是否新用户”这三个属性上建立的位图索引时,具体操作就
是将属性中的每个选项都单独作为一列。
编号 | 男 | 女 | 学生 | 教师 | 家长 |
---|---|---|---|---|---|
001 | 1 | 0 | 1 | 0 | 0 |
6.3.2 索引生效分析 107
6.3.3 索引的使用 109
6.3.4 索引的利弊 116
6.4 数据库引擎 116
6.5 数据库锁 117
6.5.1 乐观锁 118
所谓乐观是指乐观锁认为当前操作的对象上大概率不会存在并发,不需要被操作对象加锁。乐观锁采用如下流程来确保不会发生并发冲突。
- 如果当前数据库中被操作对象的版本号与读取时记录的版本号不一致,表明这期间被操作对象被其他操作方修改。若此时写回被操作对象则会导致并发冲突,因此不可以写回操作对象。遇到这种情况可以再次读取、处理后再尝试写入。
在实现乐观锁的过程中,最重要的是设计版本号策略。
- 版本号属性:可以直接在数据表中增加一个版本号属性,版本号的值可以采用时间戳或者自增ID。
- 被更新属性:假设我们要更新的属性为C,则只要读和写这段时间中,记录的属性C的值不发生变化,便不会引发并发冲突。只需要在写操作时校验被更新的属性C的值是否和读操作时一致即可。
乐观锁虽然被称为“锁”,但它只是一个读写策略,并未在被操作对象上增加任何限制。
6.5.2 悲观锁 119
当某个操作方在被操作对象上增加悲观锁后,其他操作方对被操作对象的操作(可能是读、写中的一种或全部, 由锁的类型决定)会被阻止,直到施加锁的操作方释放锁为止。
悲观锁可以分为以下几个类型。
- 共享锁(又称S锁、读锁)
- 排他锁(又称X锁、写锁)
- 更新锁(又称U锁) :当操作方需要先读后写时,需要给被操作对象增加U锁。U锁表明操作方获得了S锁,也预定了X锁。通过∪锁的引入,避免了死锁。
6.6 死锁 120
基于以上的死锁打破策略还产生了许多死锁预防算法,如银行家算法。银行家算法会在资源分配前计算如果同意分配某个资源会不会引发死锁,只有不会引发死锁时才会进行资源的分配。
6.7 事务 122
事务是-组操作的结合,这组操作要么全部成功,要么全部失败,不存在中间状态。
6.7.1 事务并发导致的问题 123
1.脏读
脏读是指一个事务读取了另一个事务尚未提交的数据。
2.不可重复读
不可重复读是指一个事务多次读取同一个数据, 却得到了不同的结果。 这是因为在多次读取的时间间隔中, 另一个事务修改了数据并进行了提交。
3.幻读
事务1删除表中的所有记录,然后提交,但是提交完成后却发现表中还存在记录。这是因为事务2在事务1 操作后、提交之前向表中插入了一条记录。
其区别在于,不可重复读是由其他事务修改、删除目标记录引发,通过锁定目标记录可以避免:而幻读是由其他事务插入新记录引发,要想避免幻读只能锁定整张表。因此,不可重复读是针对已有记录的,是记录层面的,而幻读是表层面的。
6.7.2 事务隔离级别 124
而实现事务隔离也很简单,只要在事务操作前给受影响的记承加锁,直到事务结束再释放锁即可。
事务隔离有很多级别,越高的隔离级别则越能减少事务 并发问题,但会损失数据库并发性能:越低的隔离级别越能提升数据库并发性能,但会引发更多的事务并发问题。
常用的数据库隔离级别。
1.读未提交
埃未提交又称为一级封锁协议。该隔离级别会在事务更新某数据前为其增加S锁,直到事务结束时释放。这样,多个事务可以都通过各自的S锁来读写同一记录,从而可能出现脏读、 不可重复读、幻读。
2.读已提交
读已提交又称为二级封锁协议。该隔离级别会在更新某数据前为其增加X锁,直到事务结束,避免了其他事各读取到未提交的数据,即防止了脏读。
6.7.3 自建事务 126
事务这一概念最早来源于数据库操作,因此事务通常也是特指数据库事务。
有一些操作总是无法封装为事务,以下面的操作序列为例:
- 向某应用发送请求。
- 向某用户发送邮件。
事务的原子性要求所有操作必须同成功或同失败,而事务中的操作毕竟有先后顺序,这就要求先进行的操作能在后面操作失败时回滚。然而,因为上述两个操作均是无法回滚的:发出去的请求无法撤销:发出去的邮件也无法收回。
在这里我们也可以总结出自建事务的技巧,即在事务中首先执行不会对外界造成影响的操作、可以完全回滚的操作,最后再执行不可回滚的操作。并且,不会对外界造成影响的操作、可以完全回滚的操作在一个事务中可以存在多个,但不可回滚的操作却只能存在一个。
6.8 巨量数据的优化 128
6.8.1 表分区 128
我们使用user表的id属性作为分区依据,使用Hash算法将user表分到了四个区中。
表分区后,对外仍然表现为一个逻辑的表。但内部数据已经分散到多个区中,于是相关读写操作便可以通过分区规则分流到各个分区中进行。
表的分区有以下优点。
- 分区后的表仍然对外体现为一个逻辑表, 这意味着业务应用不需要因为数据表的分区而做变动。
- 可以将不同的分区文件放在不同的磁盘上,从而增加了数据表存储的记录的数目。
存在四种分区方式。
- Range分区:对给定属性按照区间进行分区。
- List分区:对给定属性按照离散集合进行分区。
- Hash分区:对给定属性使用散列函数进行分区,给定属性可以是多个,散列函数可以由用户指定。
- Key分区:类似于Hash分区,但是给定属性只能是一个, 散列函数由数据库引擎提供。
首先,一个表能进行分区的数目是有限的,通常是1024个:其次,表分区过多后会出现更多的跨区查询,影响分区效果。
6.8.2 分库分表 132
分库和分表是两个独立的概念。
分库是将-个数据库中的内容拆 分到多个数据库中。 假设我们要将包含两个数据表
的某个数据库拆分成两个库,则有两种拆分思路:第一种思路, 将个表保留在原库中,另一个表移动到新拆分出的库中:第二种思路,将两个表中的内容各拆分出一半组成两个新表,然后将两个新表放入新库中。
分表就是将一个数据表中的数据拆分到多个数据表中,使得每个数据表更小,便于
索引检索、全表检索的开展。表的缩小还使得行锁、表锁的范围变小,提升了数据表的并发能力。
id | name | age | sex | |
---|---|---|---|---|
1 | name1 | e1@mail.com | 18 | 0 |
2 | name2 | e2@mail.com | 15 | 1 |
3 | name3 | e3@mail.com | 25 | 0 |
表的水平拆分结果:
id | name | age | sex | |
---|---|---|---|---|
1 | name1 | e1@mail.com | 18 | 0 |
3 | name3 | e3@mail.com | 25 | 0 |
id | name | age | sex | |
---|---|---|---|---|
2 | name2 | e2@mail.com | 15 | 1 |
表的垂直拆分结果
id | name | age | sex |
---|---|---|---|
1 | name1 | 18 | 0 |
2 | name2 | 15 | 1 |
3 | name3 | 25 | 0 |
id | |
---|---|
1 | e1@mail.com |
2 | e2@mail.com |
3 | e3@mail.com |
在选择表的拆分方式时,有一个原则就是让查询操作尽量不要跨表。
6.8.3 读写分离 134
读写分离之后,读操作可以不被X锁阻隔而一直并发进行。 对于频繁写 而少量读的系统,读写分离的提升效果就比较有限。
要想实现读写分离,有以下两个问题需要解决。
- 路由操作:根据读操作(SELECT)和写操作(ADD、UPDATE、 DELETE、创建修改表)的不同将原本指向一个数据库的操作请求分流到从库和主库上。
- 主从复制操作:将主库上的写操作同步到从库上,从而确保从库内容和主库内容一致。
1.主从复制的实现方案
既不能够在从数据库上进行写操作,又要将主数据库的内容同步到从数据库,最常用的方案是基于数据库操作日志实现的。
2.主从复制的延迟问题
主数据库接收到写请求并变更记录后,新的记录需要经过日志的写入、传输、解析后才会反映到从数据库上,因此从数据库上的内容和主数据库不是实时一致的, 存在一个时间延迟。这可能会导致操作方写入某数据后不能立即读到最新的值。
6.9 非传统数据库 138
Esther Dyson的一段话,可以很好地描述上述的面向对象编程和关系型数据库之间的矛盾:“利用表格存储对象,就像是将汽车开回家,然后拆成零件放进车库里,早晨再把汽车装配起来。”
6.9.1 内存数据库 139
内存数据库将数据存储在内存中,完全或者部分放弃了对数据的持久化,以换取更局的读写性能。完全放弃持久化是指数据只存放在内存中,断电后直接丢失数据: 部分放弃持久化是指数据存放在内存中,但每隔段时间会进行一次落库操作, 断电后会丢失尚未落库的数据。
内存数据库的这种特性使得它非常适合作为缓存使用,以应对高并发读写的场景。常用的内存数据库有Redis、Memcached. FastDB 等。
6.9.2 列存储数据库 140
在传统数据库中,数据是以记录为单位进行存放的。这意味着,我们可以很方便地查询菜条记录的几个或多个属性,因为这是一个横向的查询操作。但是,当我们要查询所有记录的某个属性时,这时便成了竖向的查询操作,此时数据库需要遍历所有记录后才能给出结果。
在一些场景下,如以统计为主的场景,我们经常要检索所有记录的某个属性,而很少检索一个记录的多个属性,此时传统数据库便不是很合适。为了解决这种需求,出现了列存储数据库。
列存储数据库会将记录的同一属性存放在一起,便于以属性为条件进行检索。常见的列存储数据库有HBase、Cassandra等。
6.9.3 面向对象数据库 140
面向对象数据库引入了类、对象、继承等概念,向数据库中读写对象时不再需要对象与关系的转化过程。常见的面向对象数据库有Db4o、ObjectDB、 ObjectStore 等。
6.9.4 文档数据库 140
关系型数据库要求将被存入的数据拆分为属性,但是有很多数据并不方便拆分出多个属性。例如一段文章,文章内容总是整体出现,它们只需要作为一个长字符串存入即可。这种没有拆分成属性的数据常被称为非结构化数据。
在文档数据库中。可以存取字符串。数字、XML、JSON等众多形式的数值。常用的文档数据库有MongoDB、CouchDB等。
6.9.5 图数据库 141
如果使用关系型数据库存储图,则一般将图拆分成点和边分别存入不同的表中。但这种在储方式割裂了图的关系,不便于完成图中遍历等操作。常用的图数据库有Neo4J、OrientDB 等。
6.10 数据库中间件 141
第7章 缓存设计 143
提升系统性能的方案,这些方案的思路可以分为两类: 一类是分流,减少每个系统处理的用户请求数:另一类是并发,提升系统处理用户请求的能力。此外,还有一种提升系统性能的思路,那就是导流——将原本触发复杂操作的请求引导到简单操作上。
缓存也是一个用空间换时间的策略,牺牲了一些空间,用于存储已经得出过的结果,在之后遇到同样的请求时,及时返回节约了时间。
7.1 缓存的收益 143
事实上,并不是所有的查询都会命中缓存。假设缓存命中率为$p$,未命中缓存的原查询时间为$T_{original}$。 则引入缓存后,获得结果对象所花费的时间期望值为:
$$T_{createKey}+T_{findKey}+p \times T_{parseValue}+(1-p) \times T_{original}$$
可见引入缓存后,无论缓存是否命中,都会新增加$T_{createKey}$和$T_{findKey}$两个额外时间。
只有当引入缓存后的查询时间远小于原查询时间时,缓存的引入才是有益的。即必须满足下列不等式:
$
T_{createKey}+T_{findKey}+p \times T_{parseValue}+(1-p) \times T_{original} << T_{original}
$
7.2 缓存的键与值 145
7.2.1 缓存的键 145
检索时间$T_{findKey}$主要与缓存的物理位置(内存中还是硬盘中)和数据结构(Map 还是List等)相关,与键相关的部分其实是比较时间$T_{compareKey}$,即比较两个键是否相等所需要的时间。
MyBatis作为一个出色的ORM框架,为数据库查询提供了两级缓存。
可见MyBatis中的CacheKey设计使用了逐步退让的方法,在准确性和高效之间取得了平衡。先用最短的时间使用摘要信息进行判断,只有在判断通过的情况下,才会逐步花费更多的时间进行详细校验。
7.2.2 缓存的值 149
缓存中的值就是需要通过缓存进行存储的数据,可以分为两大类: 序列化数据和对象数据。
如果缓存支持对象存储,可以直接将对象作为值存入缓存中。例如,我们可以直接在内存中创建一个Map作为缓存,然后直接往缓存中写入对象。这时,缓存中数据的读写不需要经过序列化和反序列化过程,更为高效。
在使用缓存存储对象数据时,一个容易忽略的问题是重复引用问题。此时如果调用方A修改了对象2的属性,便污染了缓存。之后调用方B在读取缓存中的对象2时,引用的是已经修改后的对象2。
通常,我们不想让污染缓存的情况发生。如果要解决该问题,可以在缓存中存储序列化数据,因为序列化串每次反序列化得到的总是一个新对象。
7.3 缓存的更新机制 151
缓存不是数据的提供方,它只是处在需求方和提供方之间的暂存方。而数据的正确店由提供方决定,而不是由缓存决定。这就需要缓存根据提供方数据的变化进行更新,这种机制被称为缓存的更新机制。
7.3.1 时效性更新机制 151
时效性更新是种被动的更新机制,它放弃了缓存中数据和提供方数据的实时一致性,转而保证最终一致性。 这种机制假设缓存中的数据在一定时间内是有效的, 无论提供方的数据在这段时间内如何变动。这种假设大大地降低了缓存设计的复杂度。
7.3.2 主动更新机制 152
1.Cache Aside机制
- 读操作:操作方先从缓存查询数据,如果数据存在,则直接读取缓存中的数据:如果数据不存在,则从数据提供方读数据,并在缓存中记录一份。
- 写操作:先更新数据提供方的数据,在更新结束后,让缓存中的对应数据失效。
因为读操作往往要比写操作快很多。
2.Read/Write Through机制
要想彻底避免缓存不一致的出现也很简单即进行写入操作时,直接将结果写入缓存,而由缓存再同步写入数据提供方。
在Cache Aside机制中,数据写入缓存的操作是由调用方的查询操作触发的,而在Read/Write Through机制中,则需要缓存自身完成将所有数据从数据提供方读入缓存的过程。
3.Write Behind机制
在Read/Write Through机制中,进行数据写操作时会将缓存中的数据同步写入数据提供方,这会导致写操作比较缓慢。而Write Behind机制则在此基础上进一步升级, 即让写入缓存的数据异步写入数据提供方。
7.4 缓存的清理机制 155
在清理缓存的过程中,最理想的策略是清理在未来一段时间内不被 访问的数据,而保留未来一段时间内会被频繁访问的数据,这样可以最大限度地减少对命中率$p$的影响。
我们将缓存清理机制总结成了三类:时效式清理、数目阈值式清理、非强引用式清理。
7.4.1 时效式清理 156
7.4.2 数目阈值式清理 157
最常用的策略有两种: FIFO (先进先出)策略和LRU (近期最少使用)策略。
7.4.3 非强引用式清理 161
一种更优的缓存策略应该是这样如果整个系统的空间很充足,则缓存可以占据更大的空间,以节约更多的时间:如果整个系统的空间紧张,则缓存应该减少空间的占用,将空间让渡给更为核心的模块。
7.4.4 清理策略使用实践 164
7.5 缓存的风险点 165
7.5.1 缓存穿透 165
7.5.2 缓存雪崩 166
7.5.3 缓存击穿 166
7.5.4 缓存预热 167
7.6 缓存的位置 168
缓存最早出现在CPU和内存之间,用来解决CPU和内存速率不匹配的问题。而在软件领域,缓存最常出现在服务系统和数据库之间,用来解决数据库响应时间相对较长的问题。
即在一个级联的系统中,缓存出现的位置越靠前,则越能屏蔽掉对后方系统的压力,其效益也变越大。
7.6.1 客户端缓存 169
而与用户交互的系统模块我们称为客户端。这里的客客户端是统称,它包括浏览器、电脑客户端、Android客户端、IOS客户端。
不仅是将缓存设置在客户端,还会将一些只与单一客户端有关和与整体无关的操作放在客户端中,以减轻服务端的压力。这种架构模式叫作“胖客户端”模式。
要注意的是,仅仅对传输过程加密,而保存在浏览器的时候,Cookie仍然是明文的。这些特性让Cookie看上去非常适合做缓存,但实则不然。
因访问特定的网址时,浏览器会将Cookie 信息带出发送给后端,这对于缓存而言是没有必要的,会带来不必要的网络开销。
CacheStorage可以缓存请求,存储其中的内容以请求为键、以回应为值。因此,时效不高的请求可以直接缓存在CacheStorage中,从而避免了同 一个请求对后端的多次调用。
Application Cache则可以直接缓存页面文件,这意味着只要Application Cache存在,以实现网页的离线访问。
7.6.2 静态缓存 172
以一个新闻网站为例,网站首页中的背景图、banner 图、视频、音乐等就是静态数据。这些信息可以直接缓存起来,在用户请求时直接返回,而不是每次请求时通过业务应用查询。
7.6.3 服务缓存 173
7.6.4 数据库缓存 173
7.7 写缓存 174
我们可以把写缓存理解为电路中的电容,带有纹波的电压经过电容后便变得平坦了。也可以将其理解为水库,只要水库不干涸,无论注入水库的水量如何变化,水库总以相对恒定的水量向下游放水。
7.7.1 写缓存的收益问题 175
7.7.2 写缓存实践 175
Redis、数据库、内存中的列表、消息系统等都可以作为写缓存。
第8章 可靠性设计 177
8.1 软件可靠性概述 177
8.2 软件可靠性指标 178
8.2.1 失效概率 178
8.2.2 失效强度 179
8.2.3 失效率 179
8.3 模块连接方式与可靠性 179
8.3.1 串联系统的可靠性 179
8.3.2 并联系统的可靠性 180
8.3.3 冗余系统的可靠性 180
8.3.4 模块连接方式的可靠性讨论 181
8.4 软件失效模型 181
如果系统能够容忍故障模块,但不能容忍恶意模块,我们就会称该系统能够实现非拜占庭容错。如果一个系统能够在存在恶意模块的情况下正常工作,我们就会称该系统实现了拜占庭容错。
并联系统能够实现非拜占庭容错,而冗余系统能够实现拜占庭容错。相比于并联系统,冗余系统能容忍的错误级别更高。为了实现对恶意模块的容忍,冗余系统需要的信息量更大,要求的正常模块数更多,故$R_冗 < R_并$。
8.5 可靠性设计 182
8.5.1 消除单点依赖 183
8.5.2 化串联为并联 183
8.5.3 采用集群 184
第9章 应用保护 185
9.1 应用保护概述 185
9.2 隔离 187
9.3 限流 189
9.3.1 时间窗限流法 189
9.3.2 漏桶限流法 190
漏桶限流法采用恒定的时间间隔向服务释放请求,避免了请求的波动。
在实现漏桶限流法时,需要一个存储请求的队列。当外部请求到达时,先将请求放入队列中,然后以一定的频率将这些请求释放给服务。 其工作原理就像是一个漏水的水桶。
9.3.3 令牌限流法 192
在使用令牌限流法时,一个请求必须拿到令牌才能被发送给服务进行处理。而服务器则会根据自身的工作情况向限流模块发放令牌。例如,在自身并发压力大时降低令牌的发放频率,在自身空闲时提高令牌的发放频率。反馈的引入使服务能够最高程度地发挥自身的处理能力。
9.4 降级 193
9.5 熔断 196
9.6 恢复 198
第10章 前端高性能 200
在软件系统中,良好的前端交互能够极大地提升用户体验。这要求前端界面能够给出清晰的操作指引、准确的行为判断、贴切的元素展示、流畅的界面转换等,这涉及数据读写、数值计算、图形绘制、界面排布等诸多工作,给前端的性能带来了挑战。
我们这里所说的前端,不仅仅指桌面和移动端的浏览器,也指Android与IOS等的客户端软件,以及嵌入在这些客户端软件中的浏览器。
10.1 前端工作分析 200
10.1.1 前端加载过程 200
当我们通过浏览器访问某个页面时,访问的是HTML文件的地址。浏览器就是从下载和解析这个HTML文件开始,逐步请求相关资源,然后对这些资源进行整合、渲染,最终向我们展示出一个丰富的前端页面。相关的资源包括CSS文件、JavaScript文件、图片文件、视频文件等。
HTML文件会被解析为DOM树,DOM树中包含了文件、图片、超链接等元素,而CSS文件则会被解析为样式规则。然后,DOM树会和样式规则进行连接整合,得到一个呈现树(Render Tree,这是在WebKit引擎中的称呼,在Gecko引擎中被称为Frame Tree即框架树)。接下来,浏览器引擎对呈现树中的各个元素进行布局、坐标计算等工作,最终将所有元素绘制到页面上,向我们展现出整个页面。在整个解析过程中,JavaScript可能会通过事件监听函数对解析过程进行调整和修改。
前端页面加载过程:
- 资源下载:通过请求下载页面需要的HTML文件、CSS文件、JavaScript文件、图片文件等。
- 页面解析:解析HTML文件、CSS文件、JavaScript 文件等,并进行整合绘制。
10.1.2 前端性能分析 201
要想对前端性能展开优化,需要先对前端加载的各个环节进行时间资源和空间资源的分析。许多浏览器自身的调试工具便可以完成相关的分析工作。
我们以Chrome调试工具DevTools 为例介绍前端性能分析工具的使用。
10.2 资源下载优化 203
在网络速度受限、资源数目较多、资源体积较大的情况下,资源下载过程的耗时会占据前端加载总耗时的大部分。这时,我们需要采取一些手段对资源 下载过程进行优化。
10.2.1 资源压缩 203
10.2.2 减少请求 205
1.资源合并
众多小图片可以合并成一张大图片, 典型的是雪碧图(Sprite) 。在一张大的图片中包含众多的小图片,以一张图片的形式下载,然后在使用时通过偏移(常用的是CSS中的background-position属性)使用图片的不同部分。
2.长连接长轮询与推送
HTTP 1.1支持长连接,与短连接不同,在一个长连接中可以完成多次的信息传输。
在使用长连接时,我们要确认后端开启了长连接设置,以保证能够使用长连接来避免额繁地建立与断开HTTP请求。必要时可以通过前端调试工具给出的Connection ID进行确认。
全双工通信的出现使得后端可以主动向前端推送消息,进步简化了上述操作。这
样,前端不需要频繁轮询,只需要在接收到后端的推送消息时展开对应的操作即可。这样避免了大量的无意义请求。HTML5支持的WebSocket就是支持全双工通信的技术。
10.2.3 资源缓存 210
缓存是减少资源下载时间的非常重要的途径。CDN缓存是服务端对静态资源的缓存,能够减少资源的生成和传输时间。另外是客户端本地缓存,如LocalStorage和SessionStorage等,它们能够直接避免资源的重复查询,提升前端工作效率。
页面缓存也会引入一些问题, 典型的问题就是更新不及时。该问题的解决思路有两种:第一种是更新文件名使得每次更新都产生新文件;第二种是后端验证缓存有效性。
使用更新文件名的方式时,网页主入口的index.html 文件名是固定的,而它关联的文件的名称则是在打包时随机生成的。index.html 文件不允许被缓存,而它的关联文件可以被缓存。网页被重新部署之后,客户端访问时会去获取index.html 文件,而关联文件则是从未被缓存过的县有新名称的文件,于是客户端也会去请求这些文件。这样,通过更改文件名的方式使得原有的缓存文件失效。
10.3 页面解析优化 214
10.3.1 顺应解析流程 214
网页的页面默认采用流式布局方式,这意味着任何元素的大小、位置变动都会对后面元素、内部元素的位置造成影响。当某个元素的大小、位置信息发生变动后,重新计算全局各个元素位置的过程叫作回流。这个过程对性能的消耗很大。当回流发生时,后面一定紧跟着重绘。
当页面元素的位置、样式等发生变化时,需要重新将页面元素绘制和展示出来,这个过程叫作重绘,也会消耗很大的性能。
10.3.2 应用新型前端框架 216
众多新型前端框架都引入了虛拟DOM,如React、Vue、Angular等。
10.4 懒加载 216
具有懒加载功能的页面展示的只是部分元素,当出现回流和重绘时只涉及展示出来的元素。这样,减少了操作的元素的数目,提升了页面的性能。
懒加载可以使用在很多场合,典型的是页面懒加载。在首次载入时,只载入部分长度的页面,而随着页面的滚动再不断载入后续界面。也可以用在树形组件、折叠面板、标签页等处,等到展开到对应的树节点、展开对应的面板、切换到对应的标签页时才展示其中的内容。
10.5 预操作 217
第11章 架构设计理论 219
而基于架构的软件设计(Architeture Based Software Design, ABSD) 作为一种自顶向下、逐步细化的软件设计方法,便要求在软件开发之前对软件的架构进行设计。
11.1 软件架构风格 219
在架构设计中,每个软件可以同时采用多种架构风格。例如,软件系统的整
体结构采用某种架构风格设计,而系统的几个模块却采用另种风格进行组织, 某个模块内部采用第三种架构风格完成搭建等,这都是十分普遍的。
11.1.1 管道过滤器架构风格 220
管道过滤器风格中主要定义了一组包含输入输出和处理功能的构件。不同的构件接收的输入数据、给出的输出数据、进行的处理功能可能各不相同,但只要把它们串联在一起, 便组成了一个具有完整功能的系统。
UNIX系统中的管道“|”就采用了这种风格,基于管道我们可以组建连接出处理能力丰富的函数。业务审批系统等众多系统也常采用这种架构风格。
11.1.2 面向对象架构风格 220
面向对象架构风格是目前应用十分广泛的一种架构风格,它将软件系统抽象为众多高内聚、低耦合的类,并可以实例化类得到对象,然后通过对象之间的连接组成完整的系统。
11.1.3 基于组件的架构风格 221
基于组件的架构将应用拆分成为可重用的组件,每个组件具有极高的内聚性,仅对外暴露一些操作接口。然后通过类似搭积木的方式使用各个组件搭建出一个完整的系统。
11.1.4 事件驱动架构风格 221
事件驱动架构风格是指构件不去主动调用另一个构件,而是通过广播事件来触发其他构件。当一个构件被触发时,会根据触发事件的不同执行对应的操作。
HTML中的DOM便使用了事件驱动架构风格,当一个按钮被点击时,其所有父级对象都会接收到对应的事件信息,然后各自触发不同的行为。
11.1.5 分层架构风格 221
分层架构风格是将软件系统划分为不同的层级,每个层级基于下层层级完成自身功能,并向上层层级提供服务。
OSI模型就采用了分层架构风格。
11.1.6 C/S架构风格 222
C/S架构风格即客户端/服务器架构风格。
客户端完成的主要工作有:
- 向服务端发送数据,接收服务端发来的数据。
- 对用户输入的数据进行处理,对服务端发来的数据进行处理。
- 提供用户操作界面,展示服务端的数据,接收用户输入。
服务端完成的主要工作有:
- 接收客户端发来的数据。
- 根据客户端操作请求完成数据库的读写操作。
- 负责数据库的安全性、并发性等工作。
弊端:
- 客户端程序开发复杂,且显示的内容枯燥,往往是数据库内容的直接展示。
- 客户端程序可能和运行平台绑定,难以迁移。
- 客户端程序升级复杂,往往需要维护人员人工逐个升级。
11.1.7 三层C/S架构风格 223
三层C/S架构风格是对C/S架构风格的升级。这种风格增加了一个应用服务器。
应用服务器的引入让原本两层的C/S架构风格变成了三层:数据层、功能层和表示
层。这样,客户端作为表示层仅仅负责数据的表示、用户输入的接收即可,而功能层则可以完成应用中的逻辑计算处理。这种设计减小了客户端的功能负担,因此这种架构也被称为“瘦客户端”。而原来的两层C/S结构,客户端还要完成数据处理工作,因此也被称为“胖客户端”。
三层C/S架构风格,常见的Android App、IOS App、众多桌面软件都是客户端。
缺点:客户端的升级仍然需要逐个进行,实施成本较高。
11.1.8 B/S架构风格 224
B/S架构风格即浏览器/服务器风格,可以看作三层C/S架构风格的一种特例或者是一种升级。
B/S架构风格带来的一个重大升级就是真正实现了无客户端运行。
优点:
- 如果要对表示层进行升级,只需要升级服务器上的网页即可,而不需要逐个升级客户端。这大大地降低了系统的升级成本。
- 网页的开发规范统-,实现难度较低。
- 网页有着很强的可迁移性,可以在桌面操作系统、Android系统、IOS系统及许多嵌入式系统上运行。
B/S架构风格也有一些缺点。 例如,其无法采用更强的安全校验手段,因而安全性较差、数据传输中包含网页元素从而使得响应时间较长等。
11.2 软件生命周期 225
- 需求
- 需求采集、需求确认、需求分析、需求定义、需求管理
- 设计
- 模型设计、概要设计、详细设计、方案预研、质量指标设计
- 规划
- 任务规划、人员规划、速度规划
- 开发
- 编码、集成测试、系统测试
- 使用
- 系统部署、功能升级
- 废弃
- 系统下线、资源回收、文档整理
11.2.1 需求阶段 226
需求阶段是用来确定要开发软件的各方面质量指标。
采集到的需求需要通过编写软件需求说明书(Software Requirements Specifiation,SRS)的方式定义下来。编写软件需求说明书时,尽量保证描述清晰,使得各个干系人容易理解。在需求定义之后,可以再找各个干系人确认,以确保采集到的需求是正确的。
11.2.2 模型设计 227
模型设计阶段是一个容易被忽视的阶段。而当软件比较复杂时,在概要设
计之前进行模型设计是十分必要的。模型设计旨在为软件寻找合适的理论模型,并使用理论模型指导软件设计、开发、使用等各个阶段。
例如,关系数据库便是在关系代数的模型基础上发展而来的,这使得我们可以用关系代数的并、差、交、笛卡尔积、投影、选择、连接等运算来指导关系数据库的设计,并在遇到问题时使用这些关系代数知识来解决。大数据处理引擎Spark 是以有向无环图(Directed Acyclic Graph, DAG) 为模型设计出来的,这使得Spark从模型层面便具有了并行操作、容错等能力。
11.2.3 概要设计 228
概要设计阶段是在需求、模型的基础上,将软件系统抽象成模块,然后设计出各个模块及模块之间的关系。
在这一阶段中, 可以首先根据需求和模型选择合适的软件架构风格,然后根据架构风格的指导展开设计。
概要设计阶段要在抽象化的基础上进行,而不要拘泥于实现细节。在整个过程中可以采用自顶向下的方式,并注意提升各个模块的内聚性,以便为后续的详细设计减少障碍。
11.2.4 详细设计 228
详细设计是为了完成模块内和模块间的细节设计。通常,详细设计包括数据层设计、中间层设计、表现层设计、面向对象设计等。
在数据层设计阶段需要进行数据库选型、数据库操作规划、数据表设计等。在数据表设计过程中,还常常借助E-R图(Entity Relationship Diagram,实体联系图)等工具作为设计过程的辅助。
中间层设计阶段包括编程语言与框架的选择、组件的选择、算法的设计等。在进行面向对象设计时,可以借鉴各种设计模式,并使用UML (Unified Modeling Language,统一建模语言)来辅助设计过程。
表现层设计阶段包括平台的选型、展现方式的选择、界面风格的确立、页面元素的设计等。在整个系统中,表现层与客户关系最为密切,而客户也能通过表现层直观地感受整个系统的功能。因此,在这一设计阶段可以邀请客户进行多轮的反馈。
在详细设计阶段如果遇到一些无法解决的问题,可以回溯到概要设计阶段,通过重新进行概要设计来解决。
11.2.5 质量指标设计 229
软件质量的各个维度的指标可能是互相制约的。如功能性的完善可能会使得软件更为复杂,而导致易用性的下降。因此,软件质量指标设计的过程往往不是一个针对 单一指标进行提升的过程, 而是一个在多个指标间衡量取舍的过程。
11.2.6 方案预研 229
方案预研是对方案中的关键点进行预研,
- 技术难点。
- 与核心质量维度指标相关的技术点: 能帮助我们判断当前方案是否能够达成质量维度指标的要求。
方案预研的实施有助于降低软件开发失败的风险。
11.2.7 软件开发 229
软件开发是在软件系统的部分模块详细设计完成后开始的。
第12章 高性能架构实践 231
12.1 需求概述 231
12.2 权限系统的相关理论 234
12.2.1 权限模型 234
权限系统的三要素说起。
- 主体:某项操作的发起方,通常会被称为用户,但也可能是某个模块、子系统、系统,
- 客体:被操作的对象,可以是一条记录、一个数据、一个文件等。
- 行为:主体对客体展开的操作的具体类型, 可以是执行、删除、复制、触发、读、写等。
以上三者构成了一个“主动宾"结构,从而可以完整地描述某件事情。而主体、行
为、客体则分别对应了这件事情中的主语、动词、宾语。如“管理员删除记录o”,在这个操作中,主体是“管理员”,行为是“删除”,客体是“记录o”。
如果用m表示主体,a表示行为,o表示客体,r表示操作是否可以执行的判断结果,则权限系统的工作过程f可以用下面的式子表示:
$$
r=f(m,a,o)
$$
有两种常见的权限模型,分别是访问矩阵(Access Matrix)和基于角色的访问控制
( Role-Based Access Control, RBAC)
访问矩阵模型的一个维度对应了主体,一个维度对应了客体,而两者的交叉点则对应了权限。
2.基于角色的访问控制
基于角色的访问控制(RBAC)通过引入“角色”这一概念使得用户 (主体的一种通俗说法)不再和权限直接绑定,这使得用户和权限的关系更容易管理。
假设校园管理系统规定只有每个班的班长有打开自己班级教室门的权限,而每位同学有擦自己班级教室黑板的权限。易哥是一班班长,陶普是二班班长,莉莉和露西则分别是一班和二班的普通学生, 当我们使用访问矩阵模型时。
访问矩阵:
客体\主体 | 易哥 | 莉莉 |
---|---|---|
一班教室门 | 打开 | |
一班黑板 | 擦 | 擦 |
RBAC模型: |
这时我们可以发现如图所示的关系图中存在问题。根据图所示的关系,
易哥作为班长可以获得打开教室门的权限,那么易哥也可以打开二班的教室门。RBAC 中发生的这种错误叫作水平越权。
12.2.2 访问控制方式 240
访问控制方式是指权限的管理与发放策略,它主要包括自主访问控制( Discretionary Acess Control, DAC)和强制访问控制( Mandatory Access Control, MAC) 两种。
自主访问控制方式允许具有某种访问权限的主体将自身权限的子集赋予其他主体。如Linux和Windows均采用这种访问控制方式,在这些系统中,用户可以把自己针对某个文件的权限分享给其他用户。
强制访问控制方式基于安全策略来判断主体是否对客体具有访问权限。而安全策略是由管理员集中进行控制的,主体无法覆盖安全策略。典型的门禁系统就使用强制访问控制方式。具有开启某扇门权限的用户并
不能将该权限转授给其他用户。
12.3 模型设计 241
12.3.1 模型调研 241
12.3.2 模型应用 242
12.4 概要设计 249
12.5 数据层详细设计 253
12.5.1 RBAC数据表的范式设计 253
12.5.2 RBAC数据表的反范式设计 254
12.5.3 RBAC数据表的最终设计 255
12.5.4 MatrixAuth管理类数据表设计 257
12.5.5 MatrixAuth的数据层结构 259
12.6 缓存详细设计 260
12.7 服务端详细设计 261
12.7.1 数据源动态切换 261
12.7.2 数据冗余的一致性保证 263
12.7.3 服务端的操作接口 263
12.8 客户端详细设计 265
12.8.1 可控角色的权限验证 265
12.8.2 自由角色的权限验证 267
12.8.3 用户信息、角色关联信息推送 268
12.9 MatrixAuth项目实践总结 268
12.9.1 MatrixAuth的高性能设计 268
12.9.2 需求完成度分析 270
12.9.3 MatrixAuth的使用简介 270
参考文献 273