Dunwu Blog

大道至简,知易行难

系统高性能架构

性能简介

要设计高性能的系统架构,应该有以下的思维步骤:

首先,要明确影响性能的因素有哪些?性能的指标有哪些?——做到有的放矢。

其次,要了解如何测试性能指标?性能优化,必须要有前后的效果对比,才能证明性能确实有改善。

接下来,学习针对不同场景下,不同性指标的优化策略以及具体实施方案。——见招拆招。

计算机资源

了解性能指标前,需要先知道哪些计算机资源会影响性能。一般来说,影响性能的计算机资源包括:

  • CPU
  • 内存
  • 磁盘 I/O
  • 网络 I/O
  • 数据库
  • 锁竞争

性能指标

性能测试的主要指标有:

  • 响应时间
  • 并发数
  • 吞吐量
    • QPS
    • TPS
  • 资源分配使用率

响应时间

响应时间(RT)是指从客户端发一个请求开始计时,到客户端接收到从服务器端返回的响应结果结束所经历的时间,响应时间由请求发送时间、网络传输时间和服务器处理时间三部分组成。

响应时间越短,性能越好,一般一个接口的响应时间是在毫秒级。

响应时间可以进一步细分:

  • 客户端响应时间
  • 网络响应时间
  • 服务端响应时间
  • 数据库响应时间

并发数

并发数是指系统能同时处理的请求、事务数

系统自身的 CPU 处理能力、内存、以及系统自身的线程复用、锁竞争等都会影响并发数。

吞吐量

吞吐量计算公式:

1
吞吐量 = 并发数 / 平均响应时间

吞吐量越大,性能越好

一般,系统呈现给外部的最常见的吞吐量指标,就是:

  • QPS(每秒查询数) - 即系统每秒可以处理的读请求。
  • TPS(每秒事务数) - 即系统每秒可以处理的写请求。

而在系统内部,存在以下吞吐量:

  • 磁盘吞吐量 - 体现了磁盘随机读写的性能。
  • 网络吞吐量 - 除了受限于网络带宽,CPU 的处理能力、网卡、防火墙、外部接口以及 I/O、系统 IO 算法都会影响到网络吞吐量。

资源分配使用率

通常由 CPU 占用率、内存使用率、磁盘 I/O、网络 I/O 、对象与线程数来表示资源使用率。这些指标也是系统监控的重要参数。

性能测试

性能测试手段:

  • 性能测试
  • 负载测试
  • 压力测试
  • 稳定性测试

对于 Java 应用而言,最简单的,可以使用 Jmeter 进行性能测试。

性能测试报告示例:

#### 性能测试的问题

性能测试时,需要注意一些问题:

  • 热身问题 - 系统刚开始运行时,自身可能加载缓存,JVM 可能会优化热点代码等,这些行为都可能使得前后有较大的性能差异。所以,性能测试时,应该先跳过一段热身时间,等趋于稳定后,再开始性能测试。
  • 测试结果不稳定 - 性能测试中,有很多不稳定的因素,如环境、网络等,几乎不可能每次都是一样的结果。所以应该多次测试,求平均值。
  • 多 JVM 情况下的影响 - 应尽量避免一台机器部署多个 JVM 的情况。因为任意一个 JVM 都拥有整个系统的资源使用权,所以在性能测试时,可能会彼此干扰。

性能优化策略

  1. 性能分析 - 如果请求响应慢,存在性能问题。需要对请求经历的各个环节逐一分析,排查可能出现性能瓶颈的地方,定位问题。检查监控数据,分析影响性能的主要因素:内存、磁盘、网络、CPU,可能是代码或架构设计不合理,又或者是系统资源确实不足。
  2. 性能优化 - 性能优化根据网站分层架构,大致可分为前端性能优化、应用服务性能优化、存储服务性能优化。

应用服务性能优化

缓存

网站性能优化第一定律:第一优先考虑使用缓存提升性能

缓存是用于存储数据的硬件或软件的组成部分,以使得后续更快访问相应的数据。缓存中的数据可能是提前计算好的结果、数据的副本等。

  • 单点应用可以使用进程内缓存(如:ConcurrentHashMap、Caffeine);
  • 分布式应用可以使用分布式缓存(如:Redis、Memcached),或进程缓存+分布式缓存的多级缓存方案。

缓存解决方案请参考:缓存基本原理

并发模型

高并发需要根据两个条件划分:连接数量,请求数量。

  • 海量连接(成千上万)海量请求:例如抢购,双十一等
  • 常量连接(几十上百)海量请求:例如中间件
  • 海量连接常量请求:例如门户网站
  • 常量连接常量请求:例如内部运营系统,管理系统

单服务器高性能的关键之一就是服务器采取的并发模型

  • 服务器如何管理连接。
  • 服务器如何处理请求。

以上两个设计点最终都和操作系统的 I/O 模型及进程模型相关。

  • I/O 模型:阻塞、非阻塞、同步、异步。
  • 进程模型:单进程、多进程、多线程。

PPC

PPC 是 Process Per Connection 的缩写,其含义是指每次有新的连接就新建一个进程去专门处理这个连接的请求,这是传统的 UNIX 网络服务器所采用的模型。基本的流程图是:

img

  • 父进程接受连接(图中 accept)。
  • 父进程“fork”子进程(图中 fork)。
  • 子进程处理连接的读写请求(图中子进程 read、业务处理、write)。
  • 子进程关闭连接(图中子进程中的 close)。

这种模式的缺点:

  • fork 代价高
  • 父子进程通信复杂
  • 支持的并发连接数量有限

prefork

PPC 模式中,当连接进来时才 fork 新进程来处理连接请求,由于 fork 进程代价高,用户访问时可能感觉比较慢,prefork 模式的出现就是为了解决这个问题。

顾名思义,prefork 就是提前创建进程(pre-fork)。系统在启动的时候就预先创建好进程,然后才开始接受用户的请求,当有新的连接进来的时候,就可以省去 fork 进程的操作,让用户访问更快、体验更好。prefork 的基本示意图是:

img

prefork 的实现关键就是多个子进程都 accept 同一个 socket,当有新的连接进入时,操作系统保证只有一个进程能最后 accept 成功。但这里也存在一个小小的问题:“惊群”现象,就是指虽然只有一个子进程能 accept 成功,但所有阻塞在 accept 上的子进程都会被唤醒,这样就导致了不必要的进程调度和上下文切换了。幸运的是,操作系统可以解决这个问题,例如 Linux 2.6 版本后内核已经解决了 accept 惊群问题。

prefork 模式和 PPC 一样,还是存在父子进程通信复杂、支持的并发连接数量有限的问题,因此目前实际应用也不多。Apache 服务器提供了 MPM prefork 模式,推荐在需要可靠性或者与旧软件兼容的站点时采用这种模式,默认情况下最大支持 256 个并发连接。

TPC

TPC 是 Thread Per Connection 的缩写,其含义是指每次有新的连接就新建一个线程去专门处理这个连接的请求。与进程相比,线程更轻量级,创建线程的消耗比进程要少得多;同时多线程是共享进程内存空间的,线程通信相比进程通信更简单。因此,TPC 实际上是解决或者弱化了 PPC fork 代价高的问题和父子进程通信复杂的问题。

TPC 的基本流程是:

img

  • 父进程接受连接(图中 accept)。
  • 父进程创建子线程(图中 pthread)。
  • 子线程处理连接的读写请求(图中子线程 read、业务处理、write)。
  • 子线程关闭连接(图中子线程中的 close)。

注意,和 PPC 相比,主进程不用“close”连接了。原因是在于子线程是共享主进程的进程空间的,连接的文件描述符并没有被复制,因此只需要一次 close 即可。

TPC 虽然解决了 fork 代价高和进程通信复杂的问题,但是也引入了新的问题,具体表现在:

  • 创建线程虽然比创建进程代价低,但并不是没有代价,高并发时(例如每秒上万连接)还是有性能问题。
  • 无须进程间通信,但是线程间的互斥和共享又引入了复杂度,可能一不小心就导致了死锁问题。
  • 多线程会出现互相影响的情况,某个线程出现异常时,可能导致整个进程退出(例如内存越界)。

除了引入了新的问题,TPC 还是存在 CPU 线程调度和切换代价的问题。因此,TPC 方案本质上和 PPC 方案基本类似,在并发几百连接的场景下,反而更多地是采用 PPC 的方案,因为 PPC 方案不会有死锁的风险,也不会多进程互相影响,稳定性更高。

prethread

TPC 模式中,当连接进来时才创建新的线程来处理连接请求,虽然创建线程比创建进程要更加轻量级,但还是有一定的代价,而 prethread 模式就是为了解决这个问题。

和 prefork 类似,prethread 模式会预先创建线程,然后才开始接受用户的请求,当有新的连接进来的时候,就可以省去创建线程的操作,让用户感觉更快、体验更好。

由于多线程之间数据共享和通信比较方便,因此实际上 prethread 的实现方式相比 prefork 要灵活一些,常见的实现方式有下面几种:

  • 主进程 accept,然后将连接交给某个线程处理。
  • 子线程都尝试去 accept,最终只有一个线程 accept 成功,方案的基本示意图如下:

img

Apache 服务器的 MPM worker 模式本质上就是一种 prethread 方案,但稍微做了改进。Apache 服务器会首先创建多个进程,每个进程里面再创建多个线程,这样做主要是为了考虑稳定性,即:即使某个子进程里面的某个线程异常导致整个子进程退出,还会有其他子进程继续提供服务,不会导致整个服务器全部挂掉。

prethread 理论上可以比 prefork 支持更多的并发连接,Apache 服务器 MPM worker 模式默认支持 16 × 25 = 400 个并发处理线程。

Reactor

I/O 多路复用技术归纳起来有两个关键实现点:

  • 当多条连接共用一个阻塞对象后,进程只需要在一个阻塞对象上等待,而无须再轮询所有连接,常见的实现方式有 selectepollkqueue 等。
  • 当某条连接有新的数据可以处理时,操作系统会通知进程,进程从阻塞状态返回,开始进行业务处理。

I/O 多路复用结合线程池,完美地解决了 PPC 和 TPC 的问题

Reactor 模式的核心组成部分包括 Reactor 和处理资源池(进程池或线程池),其中 Reactor 负责监听和分配事件,处理资源池负责处理事件。初看 Reactor 的实现是比较简单的,但实际上结合不同的业务场景,Reactor 模式的具体实现方案灵活多变,主要体现在:

  • Reactor 的数量可以变化:可以是一个 Reactor,也可以是多个 Reactor。
  • 资源池的数量可以变化:以进程为例,可以是单个进程,也可以是多个进程(线程类似)。

最终 Reactor 模式有这三种典型的实现方案:

  • 单 Reactor 单进程 / 线程。
  • 单 Reactor 多线程。
  • 多 Reactor 多进程 / 线程。

异步操作

异步处理不仅可以减少系统服务间的耦合度,提高扩展性,事实上,它还可以提高系统的性能。异步处理可以有效减少响应等待时间,从而提高响应速度。

异步处理一般是通过分布式消息队列的方式。

异步处理可以解决以下问题:

  • 异步响应
  • 应用解耦
  • 流量削锋
  • 日志处理
  • 消息通讯

负载均衡

在高并发场景下,使用负载均衡技术为一个应用构建一个由多台服务器组成的服务器集群,将并发访问请求分发到多台服务器上处理,避免单一服务器因负载压力过大而响应缓慢,使用户请求具有更好的响应延迟特性。

高性能集群的复杂性主要体现在需要增加一个任务分配器,以及为任务选择一个合适的任务分配算法

缓存解决方案请参考:负载均衡

代码优化

多线程

从资源利用的角度看,使用多线程的原因主要有两个:IO 阻塞和多 CPU。

线程数并非越多越好,那么启动多少线程合适呢?

有个参考公式:

1
启动线程数 = (任务执行时间 / (任务执行时间 - IO 等待时间)) * CPU 内核数

最佳启动线程数和 CPU 内核数成正比,和 IO 阻塞时间成反比。

  • 如果任务都是 CPU 计算型任务,那么线程数最多不要超过 CPU 内核数,因为启动再多线程,CPU 也来不及调度;
  • 相反,如果是任务需要等待磁盘操作,网络响应,那么多启动线程有助于任务并发,提高系统吞吐量。
线程安全问题

线程安全问题时指多个线程并发访问某个资源,导致数据混乱。

解决手段有:

  • 将对象设计为无状态对象 - 典型应用:Servlet 就是无状态对象,可以被服务器多线程并发调用处理用户请求。
  • 使用局部对象
  • 并发访问资源时使用锁 - 但是引入锁会产生性能开销,应尽量使用轻量级的锁。

资源复用

应该尽量减少那些开销很大的系统资源的创建和销毁,如数据库连接、网络通信连接、线程、复杂对象等。从编程角度,资源复用主要有两种模式:单例模式和对象池。

数据结构

根据具体场景,选择合适的数据结构。

垃圾回收

如果 Web 应用运行在 JVM 等具有垃圾回收功能的环境中,那么垃圾回收可能会对系统的性能特性产生巨大影响。立即垃圾回收机制有助于程序优化和参数调优,以及编写内存安全的代码。

存储性能优化

数据库

数据库读写分离

读写分离的基本原理是将数据库读写操作分散到不同的节点上

详细解决方案参考:读写分离

数据库分库分表

数据分片指按照某个维度将存放在单一数据库中的数据分散地存放至多个数据库或表中以达到提升性能瓶颈以及可用性的效果

详细解决方案参考:分库分表

Nosql

关系型数据库的优势在于:存储结构化数据,有利于进行各种复杂查询。

但是,它也存在一些缺点:

  • 关系数据库存储的是行记录,无法存储数据结构
  • 关系数据库的 schema 扩展很不方便
  • 关系数据库在大数据场景下 I/O 较高
  • 关系数据库的全文搜索功能比较弱

为了解决上述问题,分别诞生了解决不同问题的 Nosql 数据库。

常见的 NoSQL 数据库可以分为四类:

  • K-V 数据库:KV 存储非常适合存储不涉及过多数据关系业务关系的数据,同时能有效减少读写磁盘的次数,比 SQL 数据库存储拥有更好的读写性能,能够解决关系型数据库无法存储数据结构的问题。以 Redis 为代表。
  • 列式数据库适合于批量数据处理和即时查询,解决关系数据库大数据场景下的 I/O 问题。以 HBase 为代表。
  • 文档数据库:文档数据库(也称为文档型数据库)是旨在将半结构化数据存储为文档的一种数据库,它可以解决关系型数据库表结构 schema 扩展不方便的问题。文档数据库通常以 JSON 或 XML 格式存储数据。以 MongoDB 为代表。
  • 全文搜索引擎解决关系型数据库全文搜索功能较弱的问题。以 Elasticsearch 为代表。

详情参考:Nosql 技术选型

文件存储

机械键盘和固态硬盘

考虑使用固态硬盘替代机械键盘,因为它的读写速度更快。

B+数和 LSM 树

传统关系数据库的数据库索引一般都使用两级索引的 B+ 树 结构,树的层次最多三层。因此可能需要 5 次磁盘访问才能更新一条记录(三次磁盘访问获得数据索引及行 ID,然后再进行一次数据文件读操作及一次数据文件写操作)。

由于磁盘访问是随机的,传统机械键盘在数据随机访问时性能较差,每次数据访问都需要多次访问磁盘影响数据访问性能。

许多 Nosql 数据库中的索引采用 LSM 树 作为主要数据结构。LSM 树可视为一个 N 阶合并树。数据写操作都在内存中进行。在 LSM 树上进行一次数据更新不需要磁盘访问,速度远快于 B+ 树

RAID 和 HDFS

RAID 是 Redundant Array of Independent Disks 的缩写,中文简称为独立冗余磁盘阵列。

RAID 是一种把多块独立的硬盘(物理硬盘)按不同的方式组合起来形成一个硬盘组(逻辑硬盘),从而提供比单个硬盘更高的存储性能和提供数据备份技术。

HDFS(分布式文件系统) 更被大型网站所青睐。它可以配合 MapReduce 并发计算任务框架进行大数据处理,可以在整个集群上并发访问所有磁盘,无需 RAID 支持。

HDFS 对数据存储空间的管理以数据块(Block)为单位,默认为 64 MB。所以,HDFS 更适合存储较大的文件。

前端性能优化

浏览器访问优化

  1. 减少 HTTP 请求 - HTTP 请求需要建立通信链路,进行数据传输,开销高昂,所以减少 HTTP 请求数可以有效提高访问性能。减少 HTTP 的主要手段是合并 Css、JavaScript、图片。
  2. 使用浏览器缓存 - 因为静态资源文件更新频率低,可以缓存浏览器中以提高性能。设置 HTTP 头中的 Cache-ControlExpires 属性,可设定浏览器缓存。
  3. 启用压缩 - 在服务器端压缩静态资源文件,在浏览器端解压缩,可以有效减少传输的数据量。由于文本文件压缩率可达 80% 以上,所以可以对静态资源,如 Html、Css、JavaScrip 进行压缩。
  4. CSS 放在页面最上面,JavaScript 放在页面最下面 - 浏览器会在下载完全部的 Css 后才对整个页面进行渲染,所以最好的做法是将 Css 放在页面最上面,让浏览器尽快下载 Css;JavaScript 则相反,浏览器加载 JavaScript 后立即执行,可能会阻塞整个页面,造成页面显示缓慢,因此 JavaScript 最好放在页面最下面。
  5. 减少 Cookie 传输 - Cookie 包含在 HTTP 每次的请求和响应中,太大的 Cookie 会严重影响数据传输。

CDN

CDN 一般缓存的是静态资源。

CDN 的本质仍然是一个缓存,而且将数据缓存在离用户最近的地方,使用户已最快速度获取数据,即所谓网络访问第一跳。

反向代理

传统代理服务器位于浏览器一侧,代理浏览器将 HTTP 请求发送到互联网上,而反向代理服务器位于网站机房一侧,代理网站服务器接收 HTTP 请求。

反向代理服务器可以配置缓存功能加速 Web 请求,当用户第一次访问静态内容时,静态内容就会被缓存在反向代理服务器上。

反向代理还可以实现负载均衡,通过负载均衡构建的集群可以提高系统总体处理能力。

因为所有请求都必须先经过反向代理服务器,所以可以屏蔽一些攻击 IP,达到保护网站安全的作用。

参考资料

系统高可用架构

高可用架构简介

系统可用性的度量

系统不可用也被称作系统故障,业界通常用多个 9 来衡量系统的可用性。如 QQ 的可用性为 4 个 9,即 99.99% 可用。

1
2
网站不可用时间 = 故障修复时间点 - 故障发现时间点
网站年度可用性指标 = (1 - 网站不可用时间/年度总时间) * 100%

可用性计量表:

可用性级别 系统可用性% 宕机时间/年 宕机时间/月 宕机时间/周 宕机时间/天
不可用 90% 36.5 天 73 小时 16.8 小时 144 分钟
基本可用 99% 87.6 小时 7.3 小时 1.68 小时 14.4 分钟
较高可用 99.9% 8.76 小时 43.8 分钟 10.1 分钟 1.44 分钟
高可用 99.99% 52.56 分钟 4.38 分钟 1.01 秒 8.64 秒
极高可用 99.999% 5.26 分钟 26.28 秒 6.06 秒 0.86 秒

故障原因

系统宕机原因主要有以下:

无计划的

  • 系统级故障,包括主机、操作系统、中间件、数据库、网络、电源以及外围设备。
  • 数据和中介的故障,包括人员误操作、硬盘故障、数据乱了。
  • 还有自然灾害、人为破坏,以及供电问题等。

有计划的

  • 日常任务:备份,容量规划,用户和安全管理,后台批处理应用。
  • 运维相关:数据库维护、应用维护、中间件维护、操作系统维护、网络维护。
  • 升级相关:数据库、应用、中间件、操作系统、网络,包括硬件升级。

我们再给它们归个类。

  1. 网络问题。网络链接出现问题,网络带宽出现拥塞……
  2. 性能问题。数据库慢 SQL、Java Full GC、硬盘 IO 过大、CPU 飙高、内存不足……
  3. 安全问题。被网络攻击,如 DDoS 等。
  4. 运维问题。系统总是在被更新和修改,架构也在不断地被调整,监控问题……
  5. 管理问题。没有梳理出关键服务以及服务的依赖关系,运行信息没有和控制系统同步……
  6. 硬件问题。硬盘损坏、网卡出问题、交换机出问题、机房掉电、挖掘机问题……

什么是高可用的系统架构

通常,企业级应用系统为提高系统可用性,会采用较昂贵的软硬件设备,当然这样的设备也比较稳定。

互联网公司或一些初创型公司基于成本考虑,更多采用 PC 级软硬件设备,节约成本所付出的代价就是设备较为不稳定。服务器一年中出现几次宕机,高强度读写磁盘导致磁盘损坏等事件实属正常。

综上,硬件出现故障应视为必然的,而高可用的系统架构设计目标就是要保证当出现硬件故障时,服务依然可用,数据依然能够保存并被访问实现高可用的系统架构的主要手段是数据和服务的冗余备份及失效转移,一旦某些服务器宕机,就将服务切换到其他可用的服务器上;如果磁盘损坏,则从备份的磁盘读取数据。

大型系统的分层架构及物理服务器的分布式部署使得位于不同层次的服务器具有不同的可用性特点。关闭服务或服务器宕机时产生的影响也不相同,高可用的解决方案也差异甚大。大致可以分为:

  • 高可用的应用 - 主要手段是:负载均衡
  • 高可用的服务 - 主要手段是:分级管理、超时重试、异步调用、限流、降解、断路、幂等性设计
  • 高可用的数据 - 主要手段是:数据备份和失效转移

高可用架构理论

学习高可用架构,首先需要了解分布式基础理论:CAP 和 BASE。

然后,很多著名的分布式系统,都利用选举机制,来保证主节点宕机时的故障恢复。如果要深入理解选举机制,有必要了解:Paxos 算法Raft 算法。Paxos 和 Raft 是为了实现分布式系统中高可用架构而提出的共识性算法,已经成为业界标准。

CAP 定理又称为 CAP 原则,指的是:在一个分布式系统中, 一致性(C:Consistency)可用性(A:Availability)分区容忍性(P:Partition Tolerance),最多只能同时满足其中两项

BASE 是 基本可用(Basically Available)软状态(Soft State)最终一致性(Eventually Consistent) 三个短语的缩写。BASE 理论是对 CAP 中一致性和可用性权衡的结果,它的理论的核心思想是:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。

CAP 和 BASE 理论的详细说明请参考:分布式一致性

Paxos 和 Raft 的详细说明请参考:Paxos 算法Raft 算法

架构模式

主备复制

主备复制是最常见也是最简单的一种存储高可用方案,几乎所有的存储系统都提供了主备复制的功能,例如 MySQL、Redis、MongoDB 等。

主备复制要点:

  • 存在一主多备
  • 主机负责读&写,并定期复制数据给备机。
  • 一旦主机宕机,可以通过人工手段,将其中一个备节点作为主节点。

img

优点

  • 主备复制架构中,客户端可以不感知备机的存在。即使灾难恢复后,原来的备机被人工修改为主机后,对于客户端来说,只是认为主机的地址换了而已,无须知道是原来的备机升级为主机。
  • 主备复制架构中,主机和备机之间,只需要进行数据复制即可,无须进行状态判断和主备切换这类复杂的操作。

缺点

  • 主备复制架构中,故障后需要人工干预,无法自动恢复。

适用场景

综合主备复制架构的优缺点,内部的后台管理系统使用主备复制架构的情况会比较多,例如学生管理系统、员工管理系统、假期管理系统等,因为这类系统的数据变更频率低,即使在某些场景下丢失数据,也可以通过人工的方式补全。

主从复制

主从复制和主备复制只有一字之差,区别在于:主从复制模式中,从机要承担读操作

主从复制要点:

  • 存在一主多从
  • 主机负责读&写,并定期复制数据给从机。
  • 从机只负责读。
  • 一旦主机宕机,可以通过人工手段,将其中一个从节点作为主节点。

img

优点

  • 主从复制架构中,主机故障时,读操作相关的业务可以继续运行。
  • 主从复制架构中,从机提供读操作,发挥了硬件的性能。

缺点

  • 主从复制架构中,客户端需要感知主从关系,并将不同的操作发给不同的机器进行处理,复杂度比主备复制要高。
  • 主从复制架构中,从机提供读业务,如果主从复制延迟比较大,业务会因为数据不一致出现问题。
  • 主从复制架构中,故障时需要人工干预。

适用场景

综合主从复制的优缺点,一般情况下,写少读多的业务使用主从复制的存储架构比较多。例如,论坛、BBS、新闻网站这类业务,此类业务的读操作数量是写操作数量的 10 倍甚至 100 倍以上。

集群+分区

在主备复制和主从复制模式中,都由一个共性问题:

每个机器上存储的都是全量数据。但是,单机的数据存储量总是有上限的,当数据量上升为 TB 级甚至 PB 级数据,单机终究有无法支撑的时候。这时,就需要对数据进行分片(sharding)。

分片后的节点可以视为一个独立的子集,针对子集,任然需要保证高可用。

img

高可用的应用

应用层主要处理网站应用的业务逻辑,一个显著的特点是应用的 无状态 性。

所谓的 无状态 的应用是指应用服务器不保存业务的上下文信息,而仅根据每次请求提交的数据进行相应的业务逻辑处理,多个服务实例之间完全对等,请求提交到任意服务器,处理结果都是完全一样的。

由于无状态应用,各实例之间不用考虑数据一致性问题,所以其高可用方案相对简单。主要手段是:

  • 负载均衡
  • 分布式 Session

负载均衡

负载均衡,顾名思义,主要使用在业务量和数据量较高的情况下,当单台服务器不足以承担所有的负载压力时,通过负载均衡手段,将流量和数据分摊到一个集群组成的多台服务器上,以提高整体的负载处理能力。

无状态应用的失效转移可以利用负载均衡来实现

无状态的应用实现高可用架构十分简单,由于服务器不保存请求状态,那么所有服务器完全对等,在任意节点执行同样的请求,结果总是一致的。这种情况下,最简单的高可用方案就是使用负载均衡。

负载均衡原理可以参考:负载均衡基本原理

分布式 Session

应用服务器的高可用架构设计主要基于服务无状态这一特性。事实上,业务总是有状态的,如购物车记录用户的购买信息;用户的登录状态;最新发布的消息等等。

在分布式场景下,一个用户的 Session 如果只存储在一个服务器上,那么当负载均衡器把用户的下一个请求转发到另一个服务器上,该服务器没有用户的 Session,就可能导致用户需要重新进行登录等操作。

为了解决分布式 Session 问题,常见的解决方案有:

  • 粘性 session
  • 应用服务器间的 session 复制共享
  • 基于缓存的 session 共享 ✅

分布式会话原理可以参考:分布式会话基本原理

高可用的服务

可复用的服务为业务产品提供基础公共服务,大型系统中这些服务通常都独立分布式部署,被具体应用远程调用。可复用的服务和应用一样,一般也是无状态的服务,因此,同样可以使用负载均衡的失效转移策略来实现高可用

除此以外,还有以下手段来保证服务的高可用:

  • 分级管理
  • 超时重试
  • 异步调用
  • 过载保护
    • 限流
    • 降级
    • 断路
  • 幂等性设计

分级管理

将服务根据业务重要性进行分级管理,核心应用和服务优先使用更好的硬件,在运维响应速度上也格外迅速。

在服务部署上进行必要的隔离,避免故障的连锁反应。低优先级的服务通过启动不同的线程或部署在不同的虚拟机上进行隔离,而高优先级的服务则需要部署在不同的物理机上,核心服务和数据甚至要部署在不同地域的数据中心。

超时重试

由于服务器宕机、线程死锁等原因,可能导致应用程序对服务端的调用失去响应。所以有必要引入超时机制,一旦调用超时,服务化框架抛出异常,应用程序根据服务调度策略,选择重试或请求转移到其他机器上。

异步调用

对于需要即时响应的业务,应用在调用服务时可以通过消息队列等异步方式完成,避免一个服务失败导致整个应用请求失败的情况。当然不是所有服务调用都可以异步调用,对于获取用户信息这类调用,采用异步方式会延长响应时间,得不偿失;此外,对于那些必须确认服务调用才能继续下一步操作的应用也不适宜食用异步调用。

过载保护

过载保护的手段,一般有:限流、降级、熔断。

限流

降级是从系统功能优先级的角度考虑如何应对故障,而限流则是从用户访问压力的角度来考虑如何应对故障。限流指只允许系统能够承受的访问量进来,超出系统访问能力的请求将被丢弃。

常见的限流方式可以分为两类:基于请求限流和基于资源限流。

基于请求限流

基于请求限流指从外部访问的请求角度考虑限流,常见的方式有:限制总量、限制时间量。

限制总量的方式是限制某个指标的累积上限,常见的是限制当前系统服务的用户总量,例如某个直播间限制总用户数上限为 100 万,超过 100 万后新的用户无法进入;某个抢购活动商品数量只有 100 个,限制参与抢购的用户上限为 1 万个,1 万以后的用户直接拒绝。限制时间量指限制一段时间内某个指标的上限,例如,1 分钟内只允许 10000 个用户访问,每秒请求峰值最高为 10 万。

无论是限制总量还是限制时间量,共同的特点都是实现简单,但在实践中面临的主要问题是比较难以找到合适的阈值。

基于资源限流

基于请求限流是从系统外部考虑的,而基于资源限流是从系统内部考虑的,即:找到系统内部影响性能的关键资源,对其使用上限进行限制。常见的内部资源有:连接数、文件句柄、线程数、请求队列等。

基于资源限流相比基于请求限流能够更加有效地反映当前系统的压力,但实践中设计也面临两个主要的难点:如何确定关键资源,如何确定关键资源的阈值。

降级

降级指系统将某些业务或者接口的功能降低,可以是只提供部分功能,也可以是完全停掉所有功能。

在服务访问的高峰期,服务可能因为大量并发调用而性能下降,严重时可能会导致宕机。为了保证核心功能的正常运行,需要对服务进行降级。降级有两种手段:

拒绝服务 - 拒绝低优先级应用的调用,减少服务调用并发数,确保核心应用正常使用。或者随机拒绝部分调用,节约资源,避免要死大家一起死的惨剧。

关闭服务 - 关闭部分不重要的服务,或者服务内部关闭部分不重要的功能,以节约资源。

熔断

熔断和降级是两个比较容易混淆的概念,因为单纯从名字上看好像都有禁止某个功能的意思,但其实内在含义是不同的,原因在于降级的目的是应对系统自身的故障,而熔断的目的是应对依赖的外部系统故障的情况。

熔断机制实现的关键是需要有一个统一的 API 调用层,由 API 调用层来进行采样或者统计,如果接口调用散落在代码各处就没法进行统一处理了。

幂等性设计

服务调用失败后,调用方会将请求转发到其他服务器上,但是这个失败可能是虚假的失败。比如服务已经处理成功,但因为网络故障导致调用方没有收到应答,或等待超时。这种情况下,重新发起请求,可能会导致重复操作,如:向数据库写入两条记录。如果这个操作是比较敏感的交易操作,就会产生严重后果。

服务重复调用时无法避免的,但是只要能从业务实现上保证,重复调用和一次调用的处理结果一致,则业务就没有问题,这就是幂等性设计。

有些服务的业务天然具有幂等性,比如将用户性别设为男性,不管执行多少次,结果是一致的。但有些复杂的业务,要想保证幂等性,就需要根据全局性的 ID 去进行有效性验证,验证通过才能继续执行。

高可用的存储

对于绝大部分软件系统而言,数据都是最宝贵的虚拟资产,一旦丢失,可以说是毁灭性的打击。

保证存储高可用的主要手段是:数据备份失效转移

存储高可用架构的复杂性主要体现在:如何应对副本同步延迟和中断导致的数据一致性问题

提示:再开始学习这部分内容前,建议先学习 二、高可用架构理论

数据备份

数据备份是保证数据有多个副本,任意副本的丢失都不会导致数据的永久丢失。

  • 冷备份 - 定期将数据复制到某种存储介质。
  • 热备份
    • 异步热备方式 - 异步热备方式是指多份数据副本的写入操作异步完成,应用程序收到数据服务系统的写操作成功响应时,只写成功了一份,存储系统将会异步地写其他副本。
    • 同步热备方式 - 同步热备方式是指多份数据副本的写入操作同步完成,即应用程序收到数据服务系统的写成功响应时,多份数据都已经写操作成功。但是当应用程序收到数据写操作失败的响应式,可能有部分副本或者全部副本都已经写入成功了(因为网络或者系统故障,无法返回操作成功的响应)。

失效转移

失效转移是保证任意一个副本不可访问时,可以快速切换访问其他副本,保证系统整体可用。

失效确认

判断服务器宕机的手段有两种:心跳检测访问失败报告

对于应用程序的访问失败报告,控制中心还需要再一次发送心跳检测进行确认,以免错误判断服务器宕机。因为一旦进行数据访问的失效转移,意味着数据存储多份副本不一致,需要进行后续一系列的复杂动作。

访问转移

确认某台数据服务器宕机后,就需要将数据读写访问重新路由到其他服务器上。对于完全对等存储的服务器,当其中一台宕机后,应用程序根据配置直接切换到对等服务器上。如果存储不对等,就需要重新计算路由,选择存储服务器。

数据恢复

因为某台服务器宕机,所以数据存储的副本数目会减少,必须将副本的数目恢复到系统设定的值,否则,再有服务器宕机时,就可能出现无法访问转移,数据永久丢失的情况。因此系统需要从健康的服务器复制数据,将数据副本数目恢复到设定值。

辅助手段

异地多活

异地多活架构的关键点就是异地、多活,其中异地就是指地理位置上不同的地方,类似于“不要把鸡蛋都放在同一篮子里”;多活就是指不同地理位置上的系统都能够提供业务服务,这里的“活”是活动、活跃的意思。

异地多活架构可以分为同城异区、跨城异地、跨国异地。

异地多活架构的代价:

  • 系统复杂度会发生质的变化,需要设计复杂的异地多活架构。
  • 成本会上升,毕竟要多在一个或者多个机房搭建独立的一套业务系统。

异地多活的设计原则:

  • 保证核心业务的异地多活
  • 保证核心数据最终一致性
  • 采用多种手段同步数据
  • 只保证绝大部分用户的异地多活

异地多活设计步骤:

  • 业务分级 - 常见的分级标准有:
    • 流量大的业务
    • 核心业务
    • 盈利业务
  • 数据分类 - 常见的数据分析维度有:
    • 数据量
    • 唯一性
    • 实时性
    • 可丢实性
    • 可恢复性
  • 数据同步 - 常见的数据同步方案
    • 存储系统同步
    • 消息队列同步
    • 重复生成
  • 异常处理 - 常见异常处理措施:
    • 多通道同步
    • 同步和访问结合
    • 日志记录
    • 用户补偿

发布流程

高可用的软件质量保证的手段:

  • 自动化测试
  • 预发布验证
  • 代码控制
  • 自动化发布
  • 灰度发布

系统监控

不允许没有监控的系统上线。

  • 监控数据采集
    • 用户行为日志收集
      • 服务端日志收集 - Apache、Nginx 等几乎所有 Web 服务器都具备日志记录功能,只要开启日志记录即可。如果是服务器比较多,需要集中采集日志,通常会使用 Elastic 来进行收集。
      • 客户端日志收集 - 利用页面嵌入专门的 JavaScript 脚本可以收集用户真实的操作行为。
      • 日志分析 - 可以利用 ElasticSearch 做语义分析及搜索;利用实时计算框架 Storm、Flink 等开发日志统计与分析工具。
    • 服务器性能监控 - 收集服务器性能指标,如系统负载、内存占用、CPU 占用、磁盘 IO、网络 IO 等。常用的监控工具有:Apache SkyWalkingPinpoint 等。
    • 运行数据报告 - 应该监控一些与具体业务场景相关的技术和业务指标,如:缓存命中率、平均响应时延、TPS、QPS 等。
  • 监控管理
    • 系统报警 - 设置阈值。当达到阈值,及时触发告警(短信、邮件、通信工具均可),通过及时判断状况,防患于未然。
    • 失效转移 - 监控系统可以在发现故障的情况下主动通知应用进行失效转移。
    • 自动优雅降级
      • 优雅降级是为了应付突然爆发的访问高峰,主动关闭部分功能,释放部分资源,以保证核心功能的优先访问。
      • 系统在监控管理基础之上实现自动优雅降级,是柔性架构的理想状态。

参考资料

系统伸缩性架构

伸缩性架构是指不需要改变系统的软硬件设计,仅通过改变部署服务器数量就可以扩大或缩小系统的服务处理能力。

系统架构的伸缩性设计

不同功能进行物理分离实现伸缩

  • 纵向分离(分层后分离) - 将业务处理流程上的不同部分分离部署,实现系统伸缩性。
  • 横向分离(业务分割后分离) - 将不同的业务模块分离部署,实现系统伸缩性。

单一功能通过集群规模实现伸缩

将不同功能分离部署可以实现一定程度的伸缩性,但是随着访问量逐步增加,即使分离到最小粒度的独立部署,单一的服务器也不能满足业务规模的要求。因此必须使用服务器集群,即将相同服务部署在多态服务器上构成一个集群整体对外提供服务。

应用服务器集群的伸缩性设计

如果 HTTP 请求分发装置可以感知或者可以配置集群的服务器数量,可以及时发现集群中新上线或下线的服务器,并能向新上线的服务器分发请求,停止向已下线的服务器分发请求,那么就实现了应用服务器集群的伸缩性。

HTTP 重定向负载均衡

利用 HTTP 重定向协议实现负载均衡。

这种负载均衡方案的优点是比较简单。

缺点是浏览器需要两次请求服务器才能完成一次访问,性能较差:重定向服务器自身的处理能力有可能成为瓶颈,整个集群的伸缩性规模有限;使用 HTTP 302 响应码重定向,可能使搜索引擎判断为 SEO 作弊,降低搜索排名。

DNS 域名解析负载均衡

利用 DNS 处理域名解析请求的同时进行负载均衡处理的一种方案。

在 DNS 服务器中配置多个 A 记录,如:

1
2
3
114.100.40.1 www.mysite.com
114.100.40.2 www.mysite.com
114.100.40.3 www.mysite.com

每次域名解析请求都会根据负载均衡算法计算一个不同的 IP 地址返回,这样 A 记录中配置的多个服务器就构成一个集群,并可以实现负载均衡。

DNS 域名解析负载均衡的优点:

  • 将负载均衡的工作转交给了 DNS,省掉了网站管理维护的麻烦。
  • 同时,许多 DNS 服务器还支持基于地理位置的域名解析,即将域名解析成距离用户地理最近的一个服务器地址,这样可以加快用户访问速度,改善性能。

DNS 域名解析负载均衡的缺点:

  • DNS 是多级解析,每一级 DNS 都可能缓存 A 记录,当某台服务器下线后,即使修改了 DNS 的 A 记录,要使其生效也需要较长时间。这段时间,依然会域名解析到已经下线的服务器,导致用户访问失败。
  • DNS 的负载均衡的控制权在域名服务商那里,网站无法对其做更多改善和更强大的管理。

反向代理负载均衡

大多数反向代理服务器同时提供反向代理和负载均衡的功能。

反向代理服务器的优点是部署简单。缺点是反向代理服务器是所有请求和响应的中转站,其性能可能会成为瓶颈。

IP 负载均衡

在网络层通过修改请求目标地址进行负载均衡。

负载均衡服务器(网关服务器)在操作系统内核获取网络数据包,根据负载均衡算法计算得到一台真实 Web 服务器 10.0.0.1,然后将目的 IP 地址修改为 10.0.0.1,不需要通过用户进程。真实 Web 服务器处理完成后,响应数据包回到负载均衡服务器,负载均衡服务器再将数据包原地址修改为自身的 IP 地址(114.100.80.10)发送给浏览器。

IP 负载均衡在内核完成数据分发,所以处理性能优于反向代理负载均衡。但是因为所有请求响应都要经过负载均衡服务器,集群的最大响应数据吞吐量受制于负载均衡服务器网卡带宽。

数据链路层负载均衡

数据链路层负载均衡是指在通信协议的数据链路层修改 mac 地址进行负载均衡。

这种方式又称作三角传输方式,负载均衡数据分发过程中不修改 IP 地址,只修改目的 mac 地址,通过配置真实物理服务器集群所有机器虚拟 IP 和负载均衡服务器 IP 地址一致,从而达到不修改数据包的源地址和目的地址就可以进行数据分发的目的,由于实际处理请求的真实物理服务器 IP 和数据请求目的 IP 一致,不需要通过负载均衡服务器进行地址转换,可将响应数据包直接返回给用户浏览器,避免负载均衡服务器网卡带宽成为瓶颈。这种负载方式又称作直接路由方式。

在 Linux 平台上最好的链路层负载均衡开源产品是 **LVS(Linux Virtual Server)**。

负载均衡算法

负载均衡服务器的实现可以分为两个部分:

  1. 根据负载均衡算法和 Web 服务器列表计算得到集群中一台 Web 服务器的地址。
  2. 将请求数据发送到该地址对应的 Web 服务器上。

负载均衡算法通常有以下几种:

  • 轮询(Round Robin) - 所有请求被依次分发到每台应用服务器上,即每台服务器需要处理的请求数据都相同,适合于所有服务器硬件都相同的场景。
  • 加权轮询(Weighted Round Robin) - 根据服务器硬件性能情况,在轮询的基础上,按照配置权重将请求分发到每个服务器,高性能服务器能分配更多请求。
  • 随机(Random) - 请求被随机分配到各个应用服务器,在许多场合下,这种方案都很简单实用,因为好的随机数本身就很平均,即使应用服务器硬件配置不同,也可以使用加权随机算法。
  • 最少连接(Least Connection) - 记录每个应用服务器正在处理的连接数,将新到的请求分发到最少连接的服务器上,应该说,这是最符合负载均衡定义的算法。
  • 源地址 Hash(Source Hash) - 根据请求来源的 IP 地址进行 Hash 计算,得到应用服务器,这样来自同一个 IP 地址的请求总在同一个服务器上处理,该请求的上下文信息可以存储在这台服务器上,在一个会话周期内重复使用,从而实现会话粘滞。

分布式缓存集群的伸缩性设计

目前比较流行的分布式集群伸缩性方案就是:一致性 HASH 算法

数据存储服务集群的伸缩性设计

关系型数据库的伸缩性设计

  • 主从复制 - 主流关系型数据库一般都支持主从复制。
  • 分库 - 根据业务对数据库进行分割。制约条件是跨库的表不能进行 Join 操作。
  • 分表 - 使用数据库分片中间件,如 Cobar 等。

NoSql 数据库的伸缩性设计

一般而言,Nosql 不支持 SQL 和 ACID,但是强化了对于高可用和伸缩性的支持。

参考资料

系统扩展性架构

扩展性和伸缩性是不同的概念:

  • 扩展性(Extensibility) - 指对现有系统影响最小的情况下,系统功能可持续扩展或提升的能力。表现在系统基础设施稳定不需要经常变更,应用之间较少依赖和耦合,对需求变更可以敏捷响应。它是系统架构设计层面的开闭原则(对扩展开放、对修改关闭),架构设计考虑未来功能扩展,当系统增加新功能时,不需要对现有系统的结构和代码进行修改。
  • 伸缩性(Scalability) - 指系统能够通过增加减少自身资源规模的方式增减自己计算处理事务的能力。如果这种增减是成比例的,就被称作线性伸缩性。在网站架构中 ,通常指利用集群的方式增加服务器数量、提高系统的整体事务吞吐能力。

可扩展的基本思想

  • 面向流程拆分:将整个业务流程拆分为几个阶段,每个阶段作为一部分。
  • 面向服务拆分:将系统提供的服务拆分,每个服务作为一部分。
  • 面向功能拆分:将系统提供的功能拆分,每个功能作为一部分。

可扩展方式

典型的可扩展系统架构有:

  • 面向流程拆分:分层架构。
  • 面向服务拆分:SOA、微服务。
  • 面向功能拆分:微内核架构。

分层架构

分层架构的核心点时:需要保证各层之间的差异足够清晰,边界足够明显,让人看到架构图后就能看懂整个架构

分层架构是很常见的架构模式,它也叫 N 层架构,通常情况下,N 至少是 2 层。例如,C/S 架构、B/S 架构。常见的是 3 层架构(例如,MVC、MVP 架构)、4 层架构,5 层架构的比较少见,一般是比较复杂的系统才会达到或者超过 5 层,比如操作系统内核架构。

典型分层架构:

  • C/S 架构、B/S 架构
  • MVC 架构、MVP 架构
  • 逻辑分层架构

SOA

SOA 的全称是 Service Oriented Architecture,即“面向服务的架构”。

SOA 提出了 3 个关键概念。

  • 服务 - 所有业务功能都是一项服务,服务就意味着要对外提供开放的能力,当其他系统需要使用这项功能时,无须定制化开发。
  • ESB - ESB 的全称是 Enterprise Service Bus,即 “企业服务总线”。ESB 将企业中各个不同的服务连接在一起。因为各个独立的服务是异构的,如果没有统一的标准,则各个异构系统对外提供的接口是各式各样的。SOA 使用 ESB 来屏蔽异构系统对外提供各种不同的接口方式,以此来达到服务间高效的互联互通。
  • 松耦合 - 松耦合的目的是减少各个服务间的依赖和互相影响。因为采用 SOA 架构后,各个服务是相互独立运行的,甚至都不清楚某个服务到底有多少对其他服务的依赖。如果做不到松耦合,某个服务一升级,依赖它的其他服务全部故障,这样肯定是无法满足业务需求的。

微服务

微服务是去掉 ESB 后的 SOA。

微服务的问题:

  • 服务划分过细,服务间关系复杂 - 服务划分过细,单个服务的复杂度确实下降了,但整个系统的复杂度却上升了,因为微服务将系统内的复杂度转移为系统间的复杂度了。
  • 服务数量太多,团队效率急剧下降
  • 调用链太长,性能下降
  • 调用链太长,问题定位困难
  • 没有自动化支撑,无法快速交付
  • 没有服务治理,微服务数量多了后管理混乱

微服务拆分:

  • 基于业务逻辑拆分
  • 基于可扩展拆分 - 将已经成熟和改动不大的服务拆分为稳定服务,将经常变化和迭代的服务拆分为变动服务
  • 基于可靠性拆分 - 将系统中的业务模块按照优先级排序,将可靠性要求高的核心服务和可靠性要求低的非核心服务拆分开来,然后重点保证核心服务的高可用。

基础设施:

  • 服务发现、服务路由、服务容错:这是最基本的微服务基础设施。
  • 接口框架、API 网关:主要是为了提升开发效率,接口框架是提升内部服务的开发效率,API 网关是为了提升与外部服务对接的效率。
  • 自动化部署、自动化测试、配置中心:主要是为了提升测试和运维效率。
  • 服务监控、服务跟踪、服务安全:主要是为了进一步提升运维效率。

微内核

img

微内核的核心系统设计的关键技术有:插件管理、插件连接和插件通信。

插件管理

核心系统需要知道当前有哪些插件可用,如何加载这些插件,什么时候加载插件。常见的实现方法是插件注册表机制。核心系统提供插件注册表(可以是配置文件,也可以是代码,还可以是数据库),插件注册表含有每个插件模块的信息,包括它的名字、位置、加载时机(启动就加载,还是按需加载)等。

插件连接

插件连接指插件如何连接到核心系统。通常来说,核心系统必须制定插件和核心系统的连接规范,然后插件按照规范实现,核心系统按照规范加载即可。

常见的连接机制有 OSGi(Eclipse 使用)、消息模式、依赖注入(Spring 使用),甚至使用分布式的协议都是可以的,比如 RPC 或者 HTTP Web 的方式。

插件通信

插件通信指插件间的通信。虽然设计的时候插件间是完全解耦的,但实际业务运行过程中,必然会出现某个业务流程需要多个插件协作,这就要求两个插件间进行通信。由于插件之间没有直接联系,通信必须通过核心系统,因此核心系统需要提供插件通信机制。这种情况和计算机类似,计算机的 CPU、硬盘、内存、网卡是独立设计的配件,但计算机运行过程中,CPU 和内存、内存和硬盘肯定是有通信的,计算机通过主板上的总线提供了这些组件之间的通信功能。微内核的核心系统也必须提供类似的通信机制,各个插件之间才能进行正常的通信。

易扩展的系统架构

低耦合的系统更容易扩展、复用

可扩展架构的核心思想是模块化,并在此基础上,降低模块间的耦合性,提高模块的复用性

分层和分割不仅可以进行架构伸缩,也是模块化设计的重要手段,利用分层和分割的方式将软件分割为若干个低耦合的独立的组件模块,这些组件模块以消息传递及依赖调用的方式聚合成一个完整的系统。

在大型网站中,这些模块通过分布式部署的方式,独立的模块部署在独立的服务器上,从物理上分离模块间的耦合关系,进一步降低耦合性提高复用性。

利用分布式消息队列降低系统耦合性

事件驱动架构

事件驱动架构通过在低耦合的模块间传输事件消息,以保持模块的松散耦合,并借助事件消息的通信完成模块间合作。典型的事件驱动架构就是操作系统中常见的生产者消费者模式。在大型网站中,最常见的实现手段就是分布式消息队列。

分布式消息队列

消息生产者应用程序通过远程访问接口将消息推送给消息队列服务器,消息队列服务器将消息写入本地内存队列后立即返回成功响应给消息生产者。消息队列服务器根据消息订阅列表查找订阅该消息的消息消费者应用程序,将消息队列中的消息按照先进先出(FIFO)的原则将消息通过远程通信接口发送给消息消费者程序。

在伸缩性方面,由于消息队列服务器上的数据可以看作是即时处理的,因此类似于无状态的服务器,伸缩性设计比较简单。将新服务器加入分布式消息队列集群中,通知生产者服务器更改消息队列服务器列表即可。

在可用性方面,为了避免消费者进程处理缓慢,分布式消息队列服务器内存空间不足造成的问题,如果内存队列已满,会将消息写入磁盘,消息推送模块在将内存队列消息处理完成以后,将磁盘内容加载到内存队列继续处理。

利用分布式服务打造可复用的业务平台

巨无霸系统的问题:

  • 构建、部署困难
  • 代码分支管理困难
  • 数据库连接耗尽
  • 扩展业务困难

而解决巨无霸系统问题的方案就是拆分:

  • 通过纵向拆分将业务拆分多个应用或模块;
  • 通过横向拆分将可复用业务作为独立应用。

然后,需要通过一个分布式服务管理框架将这些应用或服务组织管理起来:通过接口分解系统耦合性,不同子系统通过相同的接口描述进行服务调用。常见的分布式服务管理框架如:Spring Cloud、Dubbo 等。

大型网站分布式服务的需求与特点:

  • 负载均衡
  • 失效转移
  • 高效的远程通信
  • 整合异构系统
  • 对应用最少侵入
  • 版本管理
  • 实时监控

可扩展的数据结构

传统的关系型数据库为了保证关系运算的正确性,在设计数据库表结构的时候,就需要指定表的 schema ——字段名称,数据类型等,并要遵循特定的设计范式。这些规范带来一个问题:难以面对需求变更带来的挑战,所以有人通过预先设计一些冗余字段来应对。

许多 NoSql 数据库使用 ColumnFamily 设计来设计可扩展的数据结构。

开放平台

很多大公司会利用开放平台提供大量开放性 API 使得企业和个人可以方便的接入业务。通过开放平台,可以构建生态圈,提升品牌价值以及竞争力。

开放平台不是一朝一夕完成的,这需要大量 OPEN API 的沉淀。系统架构在设计之初,应该有意识的将未来可能被复用的接口好好设计,以便于需要开放 OPEN API 时,可以便捷的暴露服务接口。

参考资料

系统安全性架构

关键词:XSS、CSRF、SQL 注入、DoS、消息摘要、加密算法、证书

认证

SSO

SSO(Single Sign On),即单点登录。所谓单点登录,就是同平台的诸多应用登陆一次,下一次就免登陆的功能。

SSO 需要解决多个异构系统之间的问题:

  • Session 共享问题
  • 跨域问题

Session 共享问题

分布式 Session 的几种实现策略:

  • 粘性 Session - 缺点:当服务器节点宕机时,将丢失该服务器节点上的所有 Session
  • 应用服务器间的 Session 复制共享 - 缺点:占用过多内存同步过程占用网络带宽以及服务器处理器时间
  • 基于缓存的 Session 共享 ✅ (推荐方案) - 不过需要程序自身控制 Session 读写,可以考虑基于 spring-session + redis 这种成熟的方案来处理。

Cookie 不能跨域!比如:浏览器不会把 www.google.com 的 cookie 传给 www.baidu.com。

这就存在一个问题:由于域名不同,用户在系统 A 登录后,浏览器记录系统 A 的 Cookie,但是访问系统 B 的时候不会携带这个 Cookie。

针对 Cookie 不能跨域 的问题,有几种解决方案:

  • 服务端生成 Cookie 后,返回给客户端,客户端解析 Cookie ,提取 Token (比如 JWT),此后每次请求都携带这个 Token。
  • 多个域名共享 Cookie,在返回 Cookie 给客户端的时候,在 Cookie 中设置 domain 白名单。
  • 将 Token 保存在 SessionStroage 中(不依赖 Cookie 就没有跨域的问题了)。

CAS

CAS 是实现 SSO 的主流方式。

CAS 分为两部分,CAS Server 和 CAS Client

  • CAS Server - 负责用户的认证工作,就像是把第一次登录用户的一个标识存在这里,以便此用户在其他系统登录时验证其需不需要再次登录。
  • CAS Client - 业务应用,需要接入 CAS Server。当用户访问我们的应用时,首先需要重定向到 CAS Server 端进行验证,要是原来登陆过,就免去登录,重定向到下游系统,否则进行用户名密码登陆操作。

术语:

  • Ticket Granting Ticket (TGT) - 可以认为是 CAS Server 根据用户名、密码生成的一张票,存在 Server 端。
  • Ticket Granting Cookie (TGC) - 其实就是一个 Cookie,存放用户身份信息,由 Server 发给 Client 端。
  • Service Ticket (ST) - 由 TGT 生成的一次性票据,用于验证,只能用一次。

CAS 工作流程:

img

  1. 用户访问 CAS Client A(业务系统),第一次访问,重定向到认证服务中心(CAS Server)。CAS Server 发现当前请求中没有 Cookie,再重定向到 CAS Server 的登录页面。重定向请求的 URL 中包含访问地址,以便认证成功后直接跳转到访问页面。
  2. 用户在登录页面输入用户名、密码等认证信息,认证成功后,CAS Server 生成 TGT,再用 TGT 生成一个 ST。然后返回 ST 和 TGC(Cookie)给浏览器。
  3. 浏览器携带 ST 再度访问之前想访问的 CAS Client A 页面。
  4. CAS Client A 收到 ST 后,向 CAS Server 验证 ST 的有效性。验证通过则允许用户访问页面。
  5. 此时,如果登录另一个 CAS Client B,会先重定向到 CAS Server,CAS Server 可以判断这个 CAS Client B 是第一次访问,但是本地有 TGC,所以无需再次登录。用 TGC 创建一个 ST,返回给浏览器。
  6. 重复类似 3、4 步骤。

img

以上了归纳总结如下:

  1. 访问服务 - 用户访问 SSO Client 资源。
  2. 定向认证 - SSO Client 重定向用户请求到 SSO Server。
  3. 用户认证 - 用户身份认证。
  4. 发放票据 - SSO Server 会产生一个 Service Ticket (ST) 并返回给浏览器。
  5. 验证票据 - 浏览器每次访问 SSO Client 时,携带 ST,SSO Client 向 SSO Server 验证票据。只有验证通过,才允许访问。
  6. 传输用户信息 - SSO Server 验证票据通过后,传输用户认证结果信息给 SSO Client。

Oauth 2.0

基本原理

OAuth 在”客户端”与”服务提供商”之间,设置了一个授权层(authorization layer)。”客户端”不能直接登录”服务提供商”,只能登录授权层,以此将用户与客户端区分开来。”客户端”登录授权层所用的令牌(token),与用户的密码不同。用户可以在登录的时候,指定授权层令牌的权限范围和有效期。

“客户端”登录授权层以后,”服务提供商”根据令牌的权限范围和有效期,向”客户端”开放用户储存的资料。

OAuth 2.0 的运行流程如下图,摘自 RFC 6749。

(A)用户打开客户端以后,客户端要求用户给予授权。

(B)用户同意给予客户端授权。

(C)客户端使用上一步获得的授权,向认证服务器申请令牌。

(D)认证服务器对客户端进行认证以后,确认无误,同意发放令牌。

(E)客户端使用令牌,向资源服务器申请获取资源。

(F)资源服务器确认令牌无误,同意向客户端开放资源。

不难看出来,上面六个步骤之中,B 是关键,即用户怎样才能给于客户端授权。有了这个授权以后,客户端就可以获取令牌,进而凭令牌获取资源。

授权模式

客户端必须得到用户的授权(authorization grant),才能获得令牌(access token)。OAuth 2.0 定义了四种授权方式。

  • 授权码模式(authorization code)
  • 简化模式(implicit)
  • 密码模式(resource owner password credentials)
  • 客户端模式(client credentials)

授权码模式

授权码模式(authorization code)是功能最完整、流程最严密的授权模式。它的特点就是通过客户端的后台服务器,与”服务提供商”的认证服务器进行互动。

它的步骤如下:

(A)用户访问客户端,后者将前者导向认证服务器。

(B)用户选择是否给予客户端授权。

(C)假设用户给予授权,认证服务器将用户导向客户端事先指定的”重定向 URI”(redirection URI),同时附上一个授权码。

(D)客户端收到授权码,附上早先的”重定向 URI”,向认证服务器申请令牌。这一步是在客户端的后台的服务器上完成的,对用户不可见。

(E)认证服务器核对了授权码和重定向 URI,确认无误后,向客户端发送访问令牌(access token)和更新令牌(refresh token)。

下面是上面这些步骤所需要的参数。

A 步骤中,客户端申请认证的 URI,包含以下参数:

  • response_type:表示授权类型,必选项,此处的值固定为”code”
  • client_id:表示客户端的 ID,必选项
  • redirect_uri:表示重定向 URI,可选项
  • scope:表示申请的权限范围,可选项
  • state:表示客户端的当前状态,可以指定任意值,认证服务器会原封不动地返回这个值。

下面是一个例子。

1
2
3
GET /authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1
Host: server.example.com

C 步骤中,服务器回应客户端的 URI,包含以下参数:

  • code:表示授权码,必选项。该码的有效期应该很短,通常设为 10 分钟,客户端只能使用该码一次,否则会被授权服务器拒绝。该码与客户端 ID 和重定向 URI,是一一对应关系。
  • state:如果客户端的请求中包含这个参数,认证服务器的回应也必须一模一样包含这个参数。

下面是一个例子。

1
2
3
HTTP/1.1 302 Found
Location: https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA
&state=xyz

D 步骤中,客户端向认证服务器申请令牌的 HTTP 请求,包含以下参数:

  • grant_type:表示使用的授权模式,必选项,此处的值固定为”authorization_code”。
  • code:表示上一步获得的授权码,必选项。
  • redirect_uri:表示重定向 URI,必选项,且必须与 A 步骤中的该参数值保持一致。
  • client_id:表示客户端 ID,必选项。

下面是一个例子。

1
2
3
4
5
6
7
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb

E 步骤中,认证服务器发送的 HTTP 回复,包含以下参数:

  • access_token:表示访问令牌,必选项。
  • token_type:表示令牌类型,该值大小写不敏感,必选项,可以是 bearer 类型或 mac 类型。
  • expires_in:表示过期时间,单位为秒。如果省略该参数,必须其他方式设置过期时间。
  • refresh_token:表示更新令牌,用来获取下一次的访问令牌,可选项。
  • scope:表示权限范围,如果与客户端申请的范围一致,此项可省略。

下面是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"example",
"expires_in":3600,
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
"example_parameter":"example_value"
}

从上面代码可以看到,相关参数使用 JSON 格式发送(Content-Type: application/json)。此外,HTTP 头信息中明确指定不得缓存。

简化模式

简化模式(implicit grant type)不通过第三方应用程序的服务器,直接在浏览器中向认证服务器申请令牌,跳过了”授权码”这个步骤,因此得名。所有步骤在浏览器中完成,令牌对访问者是可见的,且客户端不需要认证。

它的步骤如下:

(A)客户端将用户导向认证服务器。

(B)用户决定是否给于客户端授权。

(C)假设用户给予授权,认证服务器将用户导向客户端指定的”重定向 URI”,并在 URI 的 Hash 部分包含了访问令牌。

(D)浏览器向资源服务器发出请求,其中不包括上一步收到的 Hash 值。

(E)资源服务器返回一个网页,其中包含的代码可以获取 Hash 值中的令牌。

(F)浏览器执行上一步获得的脚本,提取出令牌。

(G)浏览器将令牌发给客户端。

下面是上面这些步骤所需要的参数。

A 步骤中,客户端发出的 HTTP 请求,包含以下参数:

  • response_type:表示授权类型,此处的值固定为”token”,必选项。
  • client_id:表示客户端的 ID,必选项。
  • redirect_uri:表示重定向的 URI,可选项。
  • scope:表示权限范围,可选项。
  • state:表示客户端的当前状态,可以指定任意值,认证服务器会原封不动地返回这个值。

下面是一个例子。

1
2
3
GET /authorize?response_type=token&client_id=s6BhdRkqt3&state=xyz
&redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1
Host: server.example.com

C 步骤中,认证服务器回应客户端的 URI,包含以下参数:

  • access_token:表示访问令牌,必选项。
  • token_type:表示令牌类型,该值大小写不敏感,必选项。
  • expires_in:表示过期时间,单位为秒。如果省略该参数,必须其他方式设置过期时间。
  • scope:表示权限范围,如果与客户端申请的范围一致,此项可省略。
  • state:如果客户端的请求中包含这个参数,认证服务器的回应也必须一模一样包含这个参数。

下面是一个例子。

1
2
3
HTTP/1.1 302 Found
Location: http://example.com/cb#access_token=2YotnFZFEjr1zCsicMWpAA
&state=xyz&token_type=example&expires_in=3600

在上面的例子中,认证服务器用 HTTP 头信息的 Location 栏,指定浏览器重定向的网址。注意,在这个网址的 Hash 部分包含了令牌。

根据上面的 D 步骤,下一步浏览器会访问 Location 指定的网址,但是 Hash 部分不会发送。接下来的 E 步骤,服务提供商的资源服务器发送过来的代码,会提取出 Hash 中的令牌。

密码模式

密码模式(Resource Owner Password Credentials Grant)中,用户向客户端提供自己的用户名和密码。客户端使用这些信息,向”服务商提供商”索要授权。

在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分,或者由一个著名公司出品。而认证服务器只有在其他授权模式无法执行的情况下,才能考虑使用这种模式。

它的步骤如下:

(A)用户向客户端提供用户名和密码。

(B)客户端将用户名和密码发给认证服务器,向后者请求令牌。

(C)认证服务器确认无误后,向客户端提供访问令牌。

B 步骤中,客户端发出的 HTTP 请求,包含以下参数:

  • grant_type:表示授权类型,此处的值固定为”password”,必选项。
  • username:表示用户名,必选项。
  • password:表示用户的密码,必选项。
  • scope:表示权限范围,可选项。

下面是一个例子。

1
2
3
4
5
6
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=password&username=johndoe&password=A3ddj3w

C 步骤中,认证服务器向客户端发送访问令牌,下面是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"example",
"expires_in":3600,
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
"example_parameter":"example_value"
}

上面代码中,各个参数的含义参见《授权码模式》一节。

整个过程中,客户端不得保存用户的密码。

客户端模式

客户端模式(Client Credentials Grant)指客户端以自己的名义,而不是以用户的名义,向”服务提供商”进行认证。严格地说,客户端模式并不属于 OAuth 框架所要解决的问题。在这种模式中,用户直接向客户端注册,客户端以自己的名义要求”服务提供商”提供服务,其实不存在授权问题。

它的步骤如下:

(A)客户端向认证服务器进行身份认证,并要求一个访问令牌。

(B)认证服务器确认无误后,向客户端提供访问令牌。

A 步骤中,客户端发出的 HTTP 请求,包含以下参数:

  • granttype:表示授权类型,此处的值固定为”clientcredentials”,必选项。
  • scope:表示权限范围,可选项。
1
2
3
4
5
6
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials

认证服务器必须以某种方式,验证客户端身份。

B 步骤中,认证服务器向客户端发送访问令牌,下面是一个例子。

1
2
3
4
5
6
7
8
9
10
11
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache

{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"example",
"expires_in":3600,
"example_parameter":"example_value"
}

上面代码中,各个参数的含义参见《授权码模式》一节。

更新令牌

如果用户访问的时候,客户端的”访问令牌”已经过期,则需要使用”更新令牌”申请一个新的访问令牌。

客户端发出更新令牌的 HTTP 请求,包含以下参数:

  • granttype:表示使用的授权模式,此处的值固定为”refreshtoken”,必选项。
  • refresh_token:表示早前收到的更新令牌,必选项。
  • scope:表示申请的授权范围,不可以超出上一次申请的范围,如果省略该参数,则表示与上一次一致。

下面是一个例子。

1
2
3
4
5
6
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA

鉴权

RBAC

**RBAC(Role-Based Access Control)即:基于角色的权限控制**。通过角色关联用户,角色关联权限的方式间接赋予用户权限。

每个用户关联一个或多个角色,每个角色关联一个或多个权限,从而可以实现了非常灵活的权限管理。角色可以根据实际业务需求灵活创建,这样就省去了每新增一个用户就要关联一遍所有权限的麻烦。简单来说 RBAC 就是:用户关联角色,角色关联权限。

img

角色继承

角色继承(Hierarchical Role) 就是指角色可以继承于其他角色,在拥有其他角色权限的同时,自己还可以关联额外的权限。这种设计可以给角色分组和分层,一定程度简化了权限管理工作。

img

职责分离(Separation of Duty)

为了避免用户拥有过多权限而产生利益冲突,例如一个篮球运动员同时拥有裁判的权限(看一眼就给你判犯规狠不狠?),另一种职责分离扩展版的 RBAC 被提出。

职责分离有两种模式:

静态职责分离(Static Separation of Duty):用户无法同时被赋予有冲突的角色。

img

动态职责分离(Dynamic Separation of Duty):用户在一次会话(Session)中不能同时激活自身所拥有的、互相有冲突的角色,只能选择其一。

img

讲了这么多 RBAC,都还只是在用户和权限之间进行设计,并没有涉及到用户和对象之间的权限判断,而在实际业务系统中限制用户能够使用的对象是很常见的需求。

RBAC0 模型

最简单的用户、角色、权限模型。这里面又包含了 2 种:

  1. 用户和角色是多对一关系,即:一个用户只充当一种角色,一种角色可以有多个用户担当。
  2. 用户和角色是多对多关系,即:一个用户可同时充当多种角色,一种角色可以有多个用户担当。

那么,什么时候该使用多对一的权限体系,什么时候又该使用多对多的权限体系呢?

如果系统功能比较单一,使用人员较少,岗位权限相对清晰且确保不会出现兼岗的情况,此时可以考虑用多对一的权限体系。其余情况尽量使用多对多的权限体系,保证系统的可扩展性。如:张三既是行政,也负责财务工作,那张三就同时拥有行政和财务两个角色的权限。

RBAC1 模型

相对于 RBAC0 模型,增加了子角色,引入了继承概念,即子角色可以继承父角色的所有权限。

img

使用场景:如某个业务部门,有经理、主管、专员。主管的权限不能大于经理,专员的权限不能大于主管,如果采用 RBAC0 模型做权限系统,极可能出现分配权限失误,最终出现主管拥有经理都没有的权限的情况。

而 RBAC1 模型就很好解决了这个问题,创建完经理角色并配置好权限后,主管角色的权限继承经理角色的权限,并且支持在经理权限上删减主管权限。

RBAC2 模型

基于 RBAC0 模型,增加了对角色的一些限制:角色互斥、基数约束、先决条件角色等。

  • 角色互斥:同一用户不能分配到一组互斥角色集合中的多个角色,互斥角色是指权限互相制约的两个角色。案例:财务系统中一个用户不能同时被指派给会计角色和审计员角色。
  • 基数约束:一个角色被分配的用户数量受限,它指的是有多少用户能拥有这个角色。例如:一个角色专门为公司 CEO 创建的,那这个角色的数量是有限的。
  • 先决条件角色:指要想获得较高的权限,要首先拥有低一级的权限。例如:先有副总经理权限,才能有总经理权限。
  • 运行时互斥:例如,允许一个用户具有两个角色的成员资格,但在运行中不可同时激活这两个角色。

RBAC3 模型

称为统一模型,它包含了 RBAC1 和 RBAC2,利用传递性,也把 RBAC0 包括在内,综合了 RBAC0、RBAC1 和 RBAC2 的所有特点,这里就不在多描述了。

img

什么是权限

说了这么久用户-角色-权限,可能小伙伴们都了解了什么是用户、什么是角色。但是有的小伙伴会好奇,那权限又是个什么玩意呢?

权限是资源的集合,这里的资源指的是软件中所有的内容,包括模块、菜单、页面、字段、操作功能(增删改查)等等。具体的权限配置上,目前形式多种多样,按照我个人的理解,可以将权限分为:页面权限、操作权限和数据权限(这种分类法,主要是结合自己在工作中的实际情况理解总结而来,若有不足之处,也请大家指出)。

页面权限:所有系统都是由一个个的页面组成,页面再组成模块,用户是否能看到这个页面的菜单、是否能进入这个页面就称为页面权限。

如下图:

img

客户列表、客户黑名单、客户审批页面组成了客户管理这个模块。对于普通用户,不能进行审批操作,即无客户审批页面权限,在他的账号登录后侧边导航栏只显示客户列表、客户黑名单两个菜单。

操作权限:用户凡是在操作系统中的任何动作、交互都是操作权限,如增删改查等。

数据权限:一般业务管理系统,都有数据私密性的要求:哪些人可以看到哪些数据,不可以看到哪些数据。

简单举个例子:某系统中有销售部门,销售专员负责推销商品,销售主管负责管理销售专员日常工作,经理负责组织管理销售主管作业。

如下图:

img

按照实际理解,‘销售专员张三’登录时,只能看到自己负责的数据;销售主管 2 登录时,能看到他所领导的所有业务员负责的数据,但看不到其他团队业务员负责的数据。

换另外一句话就是:我的客户只有我和我的直属上级以及直属上级的领导能看到,这就是我理解的数据权限。

要实现数据权限有多种方式:

  1. 可以利用 RBAC1 模型,通过角色分级来实现。
  2. 在‘用户-角色-权限’的基础上,增加用户与组织的关联关系,用组织决定用户的数据权限。

具体如何做呢?

① 组织层级划分:

img

② 数据可视权限规则制定:上级组织只能看到下级组织员工负责的数据,而不能看到其他平级组织及其下级组织的员工数据等。

通过以上两点,系统就可以在用户登录时,自动判断要给用户展示哪些数据了。

用户组的使用

当平台用户基数增大,角色类型增多时,如果直接给用户配角色,管理员的工作量就会很大。这时候我们可以引入一个概念“用户组”,就是将相同属性的用户归类到一起。

例如:加入用户组的概念后,可以将部门看做一个用户组,再给这个部门直接赋予角色(1 万员工部门可能就几十个),使部门拥有部门权限,这样这个部门的所有用户都有了部门权限,而不需要为每一个用户再单独指定角色,极大的减少了分配权限的工作量。

同时,也可以为特定的用户指定角色,这样用户除了拥有所属用户组的所有权限外,还拥有自身特定的权限。

用户组的优点,除了减少工作量,还有更便于理解、增加多级管理关系等。如:我们在进行组织机构配置的时候,除了加入部门,还可以加入科室、岗位等层级,来为用户组内部成员的权限进行等级上的区分。

关于用户组的详细疑难解答,请查看https://wen.woshipm.com/question/detail/88fues.html。在这里也十分感谢为我解答疑惑的朋友们!

实例分析

如何设计 RBAC 权限系统

首先,我们思考一下一个简单的权限系统应该具备哪些内容?

答案显而易见,RBAC 模型:用户-角色-权限。所以最基本的我们应该具备用户、角色、权限这三个内容。

接下来,我们思考,究竟如何将三者关联起来。回顾前文,角色作为枢纽,关联用户、权限。所以在 RBAC 模型下,我们应该:创建一个角色,并为这个角色赋予相应权限,最后将角色赋予用户

将这个问题抽象为流程,如下图:

img

现在,基本的流程逻辑已经抽象出来了,接下来,分析该如何设计呢?

  • 第一步,需要角色管理列表,在角色管理列表能快速创建一个角色,且创建角色的同时能为角色配置权限,并且支持创建成功的角色列表能随时进行权限配置的的修改;
  • 第二步,需要用户管理列表,在用户管理列表能快速添加一个用户,且添加用户时有让用户关联角色的功能。

简单来说权限系统设计就包含以上两步,接下来为大家进行实例分析。

实例分析

① 创建角色列表

img

在角色列表快速创建一个角色:点击创建角色,支持创建角色时配置权限。

img

② 创建用户列表

img

在用户列表快速创建一个用户:支持用户关联角色的功能。

img

上述案例是基于最简单的 RBAC0 模型创建,适用于大部分常规的权限管理系统。

下面再分析一下 RBAC1 中角色分级具体如何设计。

  1. 在 RBAC0 的基础上,加上角色等级这个字段。
  2. 权限分配规则制定:低等级角色只能在高等级角色权限基础上进行删减权限。

具体界面呈现如下图:

img

以上就是简单的 RBAC 系统设计,若需更复杂的,还请读者根据上面的分析自行揣摩思考,尽管样式不同,但万变不离其宗,理解清楚 RBAC 模型后,结合自己的业务就可以设计出一套符合自己平台需求的角色权限系统,具体的就不再多阐述了。

审计

TODO

网站攻击

互联网环境鱼龙混杂,网站被攻击是常见现象,所以了解一些常见的网站攻击手段十分必要。下面列举比较常见的 4 种攻击手段:

XSS

概念

跨站脚本(Cross-site scripting,通常简称为XSS) 是一种网站应用程序的安全漏洞攻击,是代码注入的一种。它允许恶意用户将代码注入到网页上,其他用户在观看网页时就会受到影响。这类攻击通常包含了 HTML 以及用户端脚本语言。

XSS 攻击示例:

假如有下面一个 textbox

1
<input type="text" name="address1" value="value1from" />

value1from 是来自用户的输入,如果用户不是输入 value1from,而是输入 "/><script>alert(document.cookie)</script><!- 那么就会变成:

1
2
3
4
5
<input type="text" name="address1" value="" />
<script>
alert(document.cookie)
</script>
<!- ">

嵌入的 JavaScript 代码将会被执行。攻击的威力,取决于用户输入了什么样的脚本。

攻击手段和目的

常用的 XSS 攻击手段和目的有:

  • 盗用 cookie,获取敏感信息。
  • 利用植入 Flash,通过 crossdomain 权限设置进一步获取更高权限;或者利用 Java 等得到类似的操作。
  • 利用 iframeframeXMLHttpRequest 或上述 Flash 等方式,以(被攻击)用户的身份执行一些管理动作,或执行一些一般的如发微博、加好友、发私信等操作。
  • 利用可被攻击的域受到其他域信任的特点,以受信任来源的身份请求一些平时不允许的操作,如进行不当的投票活动。
  • 在访问量极大的一些页面上的 XSS 可以攻击一些小型网站,实现 DDoS 攻击的效果。

应对手段

  • 过滤特殊字符 - 将用户所提供的内容进行过滤,从而避免 HTML 和 Jascript 代码的运行。如 > 转义为 &gt< 转义为 &lt 等,就可以防止大部分攻击。为了避免对不必要的内容错误转移,如 3<5 中的 < 需要进行文本匹配后再转移,如:<img src= 这样的上下文中的 < 才转义。
  • 设置 Cookie 为 HttpOnly - 设置了 HttpOnly 的 Cookie 可以防止 JavaScript 脚本调用,就无法通过 document.cookie 获取用户 Cookie 信息。

:point_right: 参考阅读:

CSRF

概念

**跨站请求伪造(Cross-site request forgery,CSRF)**,也被称为 one-click attack 或者 session riding,通常缩写为 CSRF 或者 XSRF。它是一种挟持用户在当前已登录的 Web 应用程序上执行非本意的操作的攻击方法。和跨站脚本(XSS)相比,XSS 利用的是用户对指定网站的信任,CSRF 利用的是网站对用户网页浏览器的信任。

攻击手段和目的

可以如此理解 CSRF:攻击者盗用了你的身份,以你的名义发送恶意请求。

CSRF 能做的事太多:

  • 以你名义发送邮件,发消息
  • 用你的账号购买商品
  • 用你的名义完成虚拟货币转账
  • 泄露个人隐私

应对手段

  • 表单 Token - CSRF 是一个伪造用户请求的操作,所以需要构造用户请求的所有参数才可以。表单 Token 通过在请求参数中添加随机数的办法来阻止攻击者获得所有请求参数。
  • 验证码 - 请求提交时,需要用户输入验证码,以避免用户在不知情的情况下被攻击者伪造请求。
  • Referer check - HTTP 请求头的 Referer 域中记录着请求资源,可通过检查请求来源,验证其是否合法。

:point_right: 参考阅读:

SQL 注入

概念

**SQL 注入攻击(SQL injection)**,是发生于应用程序之数据层的安全漏洞。简而言之,是在输入的字符串之中注入 SQL 指令,在设计不良的程序当中忽略了检查,那么这些注入进去的指令就会被数据库服务器误认为是正常的 SQL 指令而运行,因此遭到破坏或是入侵。

攻击示例:

考虑以下简单的登录表单:

1
2
3
4
5
<form action="/login" method="POST">
<p>Username: <input type="text" name="username" /></p>
<p>Password: <input type="password" name="password" /></p>
<p><input type="submit" value="登陆" /></p>
</form>

我们的处理里面的 SQL 可能是这样的:

1
2
3
username:=r.Form.Get("username")
password:=r.Form.Get("password")
sql:="SELECT * FROM user WHERE username='"+username+"' AND password='"+password+"'"

如果用户的输入的用户名如下,密码任意

1
myuser' or 'foo' = 'foo' --

那么我们的 SQL 变成了如下所示:

1
SELECT * FROM user WHERE username='myuser' or 'foo' = 'foo' --'' AND password='xxx'

在 SQL 里面 -- 是注释标记,所以查询语句会在此中断。这就让攻击者在不知道任何合法用户名和密码的情况下成功登录了。

对于 MSSQL 还有更加危险的一种 SQL 注入,就是控制系统,下面这个可怕的例子将演示如何在某些版本的 MSSQL 数据库上执行系统命令。

1
2
sql:="SELECT * FROM products WHERE name LIKE '%"+prod+"%'"
Db.Exec(sql)

如果攻击提交 a%' exec master..xp_cmdshell 'net user test testpass /ADD' -- 作为变量 prod 的值,那么 sql 将会变成

1
sql:="SELECT * FROM products WHERE name LIKE '%a%' exec master..xp_cmdshell 'net user test testpass /ADD'--%'"

MSSQL 服务器会执行这条 SQL 语句,包括它后面那个用于向系统添加新用户的命令。如果这个程序是以 sa 运行而 MSSQLSERVER 服务又有足够的权限的话,攻击者就可以获得一个系统帐号来访问主机了。

虽然以上的例子是针对某一特定的数据库系统的,但是这并不代表不能对其它数据库系统实施类似的攻击。针对这种安全漏洞,只要使用不同方法,各种数据库都有可能遭殃。

攻击手段和目的

  • 数据表中的数据外泄,例如个人机密数据,账户数据,密码等。
  • 数据结构被黑客探知,得以做进一步攻击(例如 SELECT * FROM sys.tables)。
  • 数据库服务器被攻击,系统管理员账户被窜改(例如 ALTER LOGIN sa WITH PASSWORD='xxxxxx')。
  • 获取系统较高权限后,有可能得以在网页加入恶意链接、恶意代码以及 XSS 等。
  • 经由数据库服务器提供的操作系统支持,让黑客得以修改或控制操作系统(例如 xp_cmdshell “net stop iisadmin”可停止服务器的 IIS 服务)。
  • 破坏硬盘数据,瘫痪全系统(例如 xp_cmdshell “FORMAT C:”)。

应对手段

  • 使用参数化查询 - 建议使用数据库提供的参数化查询接口,参数化的语句使用参数而不是将用户输入变量嵌入到 SQL 语句中,即不要直接拼接 SQL 语句。例如使用 database/sql 里面的查询函数 PrepareQuery ,或者 Exec(query string, args ...interface{})
  • 单引号转换 - 在组合 SQL 字符串时,先针对所传入的参数进行字符替换(将单引号字符替换为连续 2 个单引号字符)。

:point_right: 参考阅读:

DoS

**拒绝服务攻击(denial-of-service attack, DoS)亦称洪水攻击**,是一种网络攻击手法,其目的在于使目标电脑的网络或系统资源耗尽,使服务暂时中断或停止,导致其正常用户无法访问。

当黑客使用网络上两个或以上被攻陷的电脑作为“僵尸”向特定的目标发动“拒绝服务”式攻击时,称为分布式拒绝服务攻击(distributed denial-of-service attack,缩写:DDoS attack、DDoS)。

攻击方式

  • 带宽消耗型攻击
  • 资源消耗型攻击

应对手段

  • 防火墙 - 允许或拒绝特定通讯协议,端口或 IP 地址。当攻击从少数不正常的 IP 地址发出时,可以简单的使用拒绝规则阻止一切从攻击源 IP 发出的通信。
  • 路由器、交换机 - 具有速度限制和访问控制能力。
  • 流量清洗 - 通过采用抗 DoS 软件处理,将正常流量和恶意流量区分开。

:point_right: 参考阅读:

加密技术

对于网站来说,用户信息、账户等等敏感数据一旦泄漏,后果严重,所以为了保护数据,应对这些信息进行加密处理。

信息加密技术一般分为:

  • 消息摘要
  • 加密算法
    • 对称加密
    • 非对称加密
  • 证书

消息摘要

常用数字签名算法:MD5、SHA 等。

应用场景:将用户密码以消息摘要形式保存到数据库中。

:point_right: 参考阅读: Java 编码和加密

加密算法

对称加密

对称加密指加密和解密所使用的密钥是同一个密钥。

常用对称加密算法:DES、AES 等。

应用场景:Cookie 加密、通信机密等。

非对称加密

非对称加密指加密和解密所使用的不是同一个密钥,而是一个公私钥对。用公钥加密的信息必须用私钥才能解开;反之,用私钥加密的信息只有用公钥才能解开。

常用非对称加密算法:RSA 等。

应用场景:HTTPS 传输中浏览器使用的数字证书实质上是经过权威机构认证的非对称加密公钥。

:point_right: 参考阅读: Java 编码和加密

密钥安全管理

保证密钥安全的方法:

  1. 把密钥和算法放在一个独立的服务器上,对外提供加密和解密服务,应用系统通过调用这个服务,实现数据的加解密。
  2. 把加解密算法放在应用系统中,密钥则放在独立服务器中,为了提高密钥的安全性,实际存储时,密钥被切分成数片,加密后分别保存在不同存储介质中。

证书

证书可以称为信息安全加密的终极手段。公开密钥认证(英语:Public key certificate),又称公开密钥证书、公钥证书、数字证书(digital certificate)、数字认证、身份证书(identity certificate)、电子证书或安全证书,是用于公开密钥基础建设的电子文件,用来证明公开密钥拥有者的身份。此文件包含了公钥信息、拥有者身份信息(主体)、以及数字证书认证机构(发行者)对这份文件的数字签名,以保证这个文件的整体内容正确无误。

透过信任权威数字证书认证机构的根证书、及其使用公开密钥加密作数字签名核发的公开密钥认证,形成信任链架构,已在 TLS 实现并在万维网的 HTTP 以 HTTPS、在电子邮件的 SMTP 以 STARTTLS 引入并广泛应用。

众所周知,常见的应用层协议 HTTP、FTP、Telnet 本身不保证信息安全。但是加入了 SSL/TLS 加密数据包机制的 HTTPS、FTPS、Telnets 是信息安全的。传输层安全性协议(Transport Layer Security, TLS),及其前身安全套接层(Secure Sockets Layer, SSL)是一种安全协议,目的是为互联网通信,提供安全及数据完整性保障。

证书原理

SSL/TLS 协议的基本思路是采用公钥加密法,也就是说,客户端先向服务器端索要公钥,然后用公钥加密信息,服务器收到密文后,用自己的私钥解密。

这里有两个问题:

(1)如何保证公钥不被篡改?

解决方法:将公钥放在数字证书中。只要证书是可信的,公钥就是可信的。

(2)公钥加密计算量太大,如何减少耗用的时间?

解决方法:每一次对话(session),客户端和服务器端都生成一个”对话密钥”(session key),用它来加密信息。由于”对话密钥”是对称加密,所以运算速度非常快,而服务器公钥只用于加密”对话密钥”本身,这样就减少了加密运算的消耗时间。

SSL/TLS 协议的基本过程是这样的:

  1. 客户端向服务器端索要并验证公钥。
  2. 双方协商生成”对话密钥”。
  3. 双方采用”对话密钥”进行加密通信。

:point_right: 参考阅读:

信息过滤

在网络中,广告和垃圾信息屡见不鲜,泛滥成灾。

常见的信息过滤与反垃圾手段有:

文本匹配

解决敏感词过滤。系统维护一份敏感词清单,如果信息中含有敏感词,则自动进行过滤或拒绝信息。

黑名单

黑名单就是将一些已经被识别出有违规行为的 IP、域名、邮箱等加入黑名单,拒绝其请求。

黑名单可以通过 Hash 表来实现,方法简单,复杂度小,适于一般应用场景。

但如果黑名单列表非常大时,Hash 表要占用很大的内存空间,这时就不再使用了。这种情况下,可以使用布隆过滤器来实现,即通过一个二进制列表和一组随机数映射函数来实现。

分类算法

对于海量信息,难以通过人工去审核。对广告贴。

垃圾邮件等内容的识别比较好的自动化方法就是采用分类算法。

简单来说,即将批量已分类的样本输入分类算法进行训练,得到一个分类模型,然后利用分类算法结合分类模型去对信息进行识别。想了解具体做法,需要去理解机器学习相关知识。

风险控制

网络给商务、金融领域带来极大便利的同时,也将风险带给了对网络安全一无所知的人们。由于交易双方信息的不对等,使得交易存在着风险,而当交易发生在网络上时,风险就更加难以控制了。

风险种类

  • 账户风险 - 盗用账户、恶意注册账户等
  • 买家风险 - 虚假询盘、恶意拒收、恶意下单、黄牛党抢购热门商品等
  • 卖家风险 - 虚假发货、出售违禁品、侵权等
  • 交易风险 - 信用卡盗刷、交易欺诈、洗钱、套现、电信诈骗等

风险控制手段

大型电商网站系统或金融系统都配备专业的风控团队进行风险控制。风险控制手段既包括人工审核也包括自动审核。

自动风控的技术手段主要有规则引擎和统计模型。

规则引擎

在交易中,买家、卖家的某些指标满足一定条件时,就会被认为存在风险。如:交易金额超过某个数值;用户来自黑名单;用户和上次登录的地址距离差距很大;用户在一定时间内频繁交易等等。

如果以上这些条件都通过 if … else … 式样的代码去实现,代码维护、扩展会非常不便。因此,就有了规则引擎来处理这类问题。规则引擎是一种将业务规则和规则处理逻辑相分离的技术,业务规则由运营人员通过管理界面去编辑,实现无需修改代码,即可实时的使用新规则。

统计模型

规则引擎虽然技术简单,但是随着规则不断增加,规模越来越大。可能会出现规则冲突,难以维护的情况,并且规则越多,性能也越差。

为了解决这种问题,就有了统计模型。统计模型会使用分类算法或更复杂的机器学习算法进行智能统计。根据历史交易中的信息训练分类,然后将经过采集加工后的交易信息输入分类算法,得到交易风险值,然后基于此,做出预测。

经过充分训练后的统计模型,准确率不低于规则引擎。但是,需要有领域专家、行业专家介入,建立合理的训练模型,并不断优化。

参考资料

秒杀系统设计

秒杀系统所要应对的场景就是:瞬时海量请求

秒杀系统的难点

  • 高并发:秒杀系统是极致的高并场景发自不用说。其高并发可以细分为二:
    • 并发读:主要是读取剩余库存量以及商品信息
    • 并发写:主要是下单后,系统写入订单记录
  • 超卖:秒杀系统中售卖的商品一般都是性价比很高,不怎么赚钱,甚至赔钱赚哟喝的商品。一旦出现超卖现象,会给商家带来巨大的经济损失。从系统层面来看,比如某秒杀商品本来库存 100 件,但是在高并发场景下,瞬时下单量超过 100 件,处理不当,让这些下单都成功了,就会出现超卖。
  • 恶意请求:有些人为了低价购入秒杀商品,通过在多台机器上跑脚本,模拟大量用户抢商品的请求(走自己的路,让别人无路可走)。
  • 数据库崩溃:海量请求下,如果没有 MQ 削峰,没有过载保护,让所有请求都打到数据库,那么数据库基本就挂了。数据库如果挂了,也会波及其他业务,从而可能让整个系统、网站陷入瘫痪。
  • 对现有业务造成冲击

秒杀系统的思考

稳准快

秒杀系统架构的思考角度可以概括为:稳、准、快

  • 稳(高可用):系统架构要满足高可用,系统要能撑住活动。
  • 准(一致性):商品减库存方式非常关键,不能出现超卖。
  • 快(高性能):整个请求链路,从前端到后端,依赖组件都要做到协同优化。

img

前端优化

静态页面

把秒杀商品页面静态化,减少查数据库的 IO 开销。然后,可以将这些静态页面做 CDN 缓存,如果项目是前后端分离的,还可以在反向代理服务器侧设置静态缓存。

如每个商品都由 ID 来标识,那么 http://item.xxx.com/item.htm?id=xxxx 就可以作为唯一的 URL 标识。相应的页面可以提前做前端缓存,这样就不需要向后台查询商品信息。

按钮控制

在秒杀活动开启时间前,下单按钮禁用。

此外,按钮一旦点击之后,禁用一段时间,防止有人疯狂输出。

后端优化

隔离

秒杀活动,本质上还是一个营销活动,性质和打折、促销一样。

秒杀系统设计底线原则,是不应该影响现有业务。所以,为了避免防不胜防,百密一疏的情况下,秒杀系统崩了。

限流、熔断、降级、隔离

  • 隔离:将秒杀系统、数据与其他正常业务隔离。彼此隔离,自然互不影响。

  • 限流:设置阈值,超过阈值,拒绝请求。防止数据库被打死。

  • 降级:保证核心业务继续工作,非核心业务各安天命。

  • 熔断:不要影响别的系统。

缓存

缓存要预热,避免瞬间流量冲击。

此外,防止雪崩、穿透、击穿问题的常规处理要做好。

缓存也要保证高可用。

流量削峰

削峰的思路:排队、答题、分层过滤。

  • 排队:用消息队列来缓冲瞬时流量的方案。但是,消息队列自身也有上限,如果积压过多,也会处理不了。
  • 答题(摇一摇):可以限制秒杀器并延缓请求。
  • 分层过滤:采用漏斗式的设计尽可能拦截无效请求。

img

减库存

恶意下单

恶意下单的解决方案还是要结合安全和反作弊措施来制止:

  • 识别频繁下单不付款或重复下单不付款的卖家,阻断其下单。
  • 限制个人购买数

避免超卖

减库存在数据一致性上,主要就是保证大并发请求时库存数据不能为负数,也就是要保证数据库中的库存字段值不能为负数,一般我们有多种解决方案:一种是在应用程序中通过事务来判断,即保证减后库存不能为负数,否则就回滚;另一种办法是直接设置数据库的字段数据为无符号整数,这样减后库存字段值小于零时会直接执行 SQL 语句来报错;再有一种就是使用 CASE WHEN 判断语句,例如这样的 SQL 语句:

1
UPDATE item SET inventory = CASE WHEN inventory >= xxx THEN inventory-xxx ELSE inventory END

在交易环节中,“库存”是个关键数据,也是个热点数据,因为交易的各个环节中都可能涉及对库存的查询。但是,我在前面介绍分层过滤时提到过,秒杀中并不需要对库存有精确的一致性读,把库存数据放到缓存(Cache)中,可以大大提升读性能。

URL 动态化

通过 MD5 之类的加密算法加密随机的字符串去做 url,然后通过前端代码获取 url 后台校验才能通过。

参考资料

Eclipse 快速入门

代码智能提示

Java 智能提示

Window -> Preferences -> Java -> Editor -> Content Assist -> Auto Activation

img

delay 是自动弹出提示框的延时时间,我们可以修改成 100 毫秒;triggers 这里默认是”.”,只要加上”abcdefghijklmnopqrstuvwxyz”或者”abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ”,嘿嘿!这下就能做到和 VS 一样的输入每个字母都能提示啦:

img

其它类型的文件比如 HTML、JavaScript、JSP 如果也能提供提示那不是更爽了?有了第二点设置的基础,其实这些设置都是一样的。

JavaScript 智能提示

Window -> Preferences -> JavaScript-> Editor -> Content Assist -> Auto-Activation

img

HTML 智能提示

Window -> Preferences -> Web -> HTML Files -> Editor -> Content Assist -> Auto-Activation

img

保存后,我们再来输入看看,感觉真是不错呀:

img

插件安装

很多教科书上说到 Eclipse 的插件安装都是通过 Help -> Install New SoftWare 这种自动检索的方式,操作起来固然是方便,不过当我们不需要某种插件时不太容易找到要删除哪些内容,而且以后 Eclipse 版本升级的时候,通过这种方式安装过的插件都得再重新装一次。另外一种通过 Link 链接方式,就可以解决这些问题。

我们以 Eclipse 的中文汉化包插件为例,先到官方提供的汉化包地址下载一个:http://www.eclipse.org/babel/downloads.php,注意选好自己的 Eclipse 版本:

img

我的版本是 Kepler,然后进入下载页面,单击红框框中的链接,即可下载汉化包了:

img

下载完解压缩后,会有个包含 features 和 plugin 目录的 eclipse 文件夹,把这个 eclipse 放在我们的 Eclipse 安装根目录,也就是和 eclipse.exe 同一级目录下。然后仍然在这一级目录下,新建一个 links 文件夹,并在该文件夹内,建一个 language.link 的文本文件。该文本文件的名字是可以任取的,后缀名是.link,而不是.txt 哟。好了,最后一步,编辑该文件,在里面写入刚才放入的语言包的地址,并用“\”表示路径,一定要有 path= 这个前缀。

img

保存文件后,重新打开 Eclipse,熟悉的中文界面终于看到了。虽然汉化不完全,不过也够用了不是么。如果仍然出现的是英文,说明汉化失败,重新检查下 language.link 文件中配置的信息是否和汉化包的目录一致。  其它的插件安装方法也是如此,当不需要某个插件时,只需删除存放插件的目录和 links 目录下相应的 link 文件,或者改变下 link 文件里面的路径变成无效路径即可;对 Eclipse 做高版本升级时,也只需把老版存放插件的目录和 links 目录复制过去就行了。

基本设置

在 Preference 的搜索项中搜索 Text Editors。
可以参考我的设置:
Show line numbers
Show print margin
Insert spaces for tabs

img
设置代码的字体类型和大小:

Window -> Preferences -> General -> Appearance -> Content Assist -> Colors and Fornts,只需修改 Basic 里面的 Text Font 就可以了。

推荐 Courier New。

img

img

设置文本文件及 JSP 文件编码

Window -> Preferences -> General -> Workspace -> Text file encoding -> Other:

img

img

设置 JDK 本地 JavaDOC API 路径及源码路径

img

img

还都生成的是无意义的变量名,这样可能会对含有相同类型的变量参数的调用顺序造成干扰;

这种问题,我们把 JDK 或者相应 Jar 包的源码导入进去就能避免了:

Window -> Preferences -> Java -> Installed JREs -> Edit:

img

选中设置好的 JRE 目录,编辑,然后全选 JRE system libraries 下的所有 Jar 包,点击右边的 Source Attachment;

img

External location 下,选中 JDK 安装目录下的 src.zip 文件,一路 OK 下来。

img

设置完,我们再来看看,幸福来的好突然有木有!

img

设置 Servlet 源码或其它 Jar 包源码

img

上一步已经设置过了 JDK 的源码或 JavaDoc 路径,为啥现在又出来了呢?其实这个不难理解,因为我们使用到的类的源码并不在 JDK 的源码包中。

仔细看,我们会发现这些 Jar 包其实都在 Tomcat 根目录下的 lib 文件夹中,但是翻遍了 Tomcat 目录也没有相应的 jar 或 zip 文件呀。既然本地没有,那就去官网上找找:

http://tomcat.apache.org/download-70.cgi这里有Tomcat的安装包和源码包;

img

可以自定义一个专门用于存放 JavaSource 和 JavaDoc 的文件夹,把下载文件放到该目录下,

然后再切换到 Eclipse 下,选中没有代码提示的类或者函数, 按下 F3,点击 Change Attached Source:

img

选择我们刚才下载好的 tomcat 源码文件,一路 OK。

img

然后再回过头看看我们的代码提示,友好多了:

img

其它 Jar 包源码的设置方式也一样。

反编译插件 JD-Eclipse

无论是开发还是调试,反编译必不可少,每次都用 jd-gui 打开去看,多麻烦,干脆配置下 JD 插件,自动关联.class:

先从 http://jd.benow.ca/ 上下载离线安装包 jdeclipse_update_site.zip,解压缩后把 features、plugins 这 2 个文件夹复制到 新建文件夹 jdeclipse,然后把 jdeclipse 文件夹整个复制到 Eclipse 根目录的 dropins 文件夹下,重启 Eclipse 即可。这种方式是不是比建 link 文件更方便了?

img

打开 Eclipse,Window -> Preferences -> General - > Editors ,把 .class 文件设置关联成 jd 插件的 editor

img

Validate 优化

我们在 eclipse 里经常看到这个进程,validating… 逐个的检查每一个文件。那么如何关闭一些 validate 操作呢?

img

打开 eclipse,点击【window】菜单,选择【preferences】选项。

img

在左侧点击【validation】选项,在右侧可以看到 eclipse 进行的自动检查都有哪些内容。

img

将 Manual(手动)保持不动,将 build 里面只留下 classpath dependency Validator,其他的全部去掉。

img

最后点击【OK】按钮,保存设置。

img

以后如果需要对文件进行校验检查的时候,在文件上点击右键,点击【Validate】进行检查。

常用快捷键

快捷键 描述
Ctrl+1 快速修复(最经典的快捷键,就不用多说了,可以解决很多问题,比如 import 类、try catch 包围等)
Ctrl+Shift+F 格式化当前代码
Ctrl+Shift+M 添加类的 import 导入
Ctrl+Shift+O 组织类的 import 导入(既有 Ctrl+Shift+M 的作用,又可以帮你去除没用的导入,很有用)
Ctrl+Y 重做(与撤销 Ctrl+Z 相反)
Alt+/ 内容辅助(帮你省了多少次键盘敲打,太常用了)
Ctrl+D 删除当前行或者多行
Alt+↓ 当前行和下面一行交互位置(特别实用,可以省去先剪切,再粘贴了)
Alt+↑ 当前行和上面一行交互位置(同上)
Ctrl+Alt+↓ 复制当前行到下一行(复制增加)
Ctrl+Alt+↑ 复制当前行到上一行(复制增加)
Shift+Enter 在当前行的下一行插入空行(这时鼠标可以在当前行的任一位置,不一定是最后)
Ctrl+/ 注释当前行,再按则取消注释
Alt+Shift+↑ 选择封装元素
Alt+Shift+← 选择上一个元素
Alt+Shift+→ 选择下一个元素
Shift+← 从光标处开始往左选择字符
Shift+→ 从光标处开始往右选择字符
Ctrl+Shift+← 选中光标左边的单词
Ctrl+Shift+→ 选中光标又边的单词
Ctrl+← 光标移到左边单词的开头,相当于 vim 的 b
Ctrl+→ 光标移到右边单词的末尾,相当于 vim 的 e
Ctrl+K 参照选中的 Word 快速定位到下一个(如果没有选中 word,则搜索上一次使用搜索的 word)
Ctrl+Shift+K 参照选中的 Word 快速定位到上一个
Ctrl+J 正向增量查找(按下 Ctrl+J 后,你所输入的每个字母编辑器都提供快速匹配定位到某个单词,如果没有,则在状态栏中显示没有找到了,查一个单词时,特别实用,要退出这个模式,按 escape 建)
Ctrl+Shift+J 反向增量查找(和上条相同,只不过是从后往前查)
Ctrl+Shift+U 列出所有包含字符串的行
Ctrl+H 打开搜索对话框
Ctrl+G 工作区中的声明
Ctrl+Shift+G 工作区中的引用
Ctrl+Shift+T 搜索类(包括工程和关联的第三 jar 包)
Ctrl+Shift+R 搜索工程中的文件
Ctrl+E 快速显示当前 Editer 的下拉列表(如果当前页面没有显示的用黑体表示)
F4 打开类型层次结构
F3 跳转到声明处
Alt+← 前一个编辑的页面
Alt+→ 下一个编辑的页面(当然是针对上面那条来说了)
Ctrl+PageUp/PageDown 在编辑器中,切换已经打开的文件
F5 单步跳入
F6 单步跳过
F7 单步返回
F8 继续
Ctrl+Shift+D 显示变量的值
Ctrl+Shift+B 在当前行设置或者去掉断点
Ctrl+R 运行至行(超好用,可以节省好多的断点)
Alt+Shift+R 重命名方法名、属性或者变量名 (是我自己最爱用的一个了,尤其是变量和类的 Rename,比手工方法能节省很多劳动力)
Alt+Shift+M 把一段函数内的代码抽取成方法 (这是重构里面最常用的方法之一了,尤其是对一大堆泥团代码有用)
Alt+Shift+C 修改函数结构(比较实用,有 N 个函数调用了这个方法,修改一次搞定)
Alt+Shift+L 抽取本地变量( 可以直接把一些魔法数字和字符串抽取成一个变量,尤其是多处调用的时候)
Alt+Shift+F 把 Class 中的 local 变量变为 field 变量 (比较实用的功能)
Alt+Shift+I 合并变量(可能这样说有点不妥 Inline)
Alt+Shift+V 移动函数和变量(不怎么常用)
Alt+Shift+Z 重构的后悔药(Undo)
Alt+Enter 显示当前选择资源的属性,windows 下的查看文件的属性就是这个快捷键,通常用来查看文件在 windows 中的实际路径
Ctrl+↑ 文本编辑器 上滚行
Ctrl+↓ 文本编辑器 下滚行
Ctrl+M 最大化当前的 Edit 或 View (再按则反之)
Ctrl+O 快速显示 OutLine
Ctrl+T 快速显示当前类的继承结构
Ctrl+W 关闭当前 Editer
Ctrl+L 文本编辑器 转至行
F2 显示工具提示描述

一篇文章让你掌握 Python

解释器

Linux/Unix 的系统上,Python 解释器通常被安装在 /usr/local/bin/python3.4 这样的有效路径(目录)里。

我们可以将路径 /usr/local/bin 添加到您的 Linux/Unix 操作系统的环境变量中,这样您就可以通过 shell 终端输入下面的命令来启动 Python 。

在 Linux/Unix 系统中,你可以在脚本顶部添加以下命令让 Python 脚本可以像 SHELL 脚本一样可直接执行:

1
#! /usr/bin/env python3.4

注释

Python 中的注释有三种形式:

  • # 开头
  • ''' 开始,以 ''' 结尾
  • """ 开始,以 """ 结尾
1
2
3
4
5
6
7
8
9
10
11
12
13
# 单行注释

'''
这是多行注释,用三个单引号
这是多行注释,用三个单引号
这是多行注释,用三个单引号
'''

"""
这是多行注释,用三个双引号
这是多行注释,用三个双引号
这是多行注释,用三个双引号
"""

数据类型

Python3 中有六个标准的数据类型:

  • Numbers(数字)
  • String(字符串)
  • List(列表)
  • Tuple(元组)
  • Sets(集合)
  • Dictionaries(字典)

操作符

Python 语言支持以下类型的运算符:

  • 算术运算符

  • 比较(关系)运算符

  • 赋值运算符

  • 逻辑运算符

  • 位运算符

  • 成员运算符

  • 身份运算符

  • 运算符优先级

算术运算符

运算符 描述 实例
+ 加 - 两个对象相加 a + b 输出结果 31
- 减 - 得到负数或是一个数减去另一个数 a - b 输出结果 -11
* 乘 - 两个数相乘或是返回一个被重复若干次的字符串 a * b 输出结果 210
/ 除 - x 除以 y b / a 输出结果 2.1
% 取模 - 返回除法的余数 b % a 输出结果 1
** 幂 - 返回 x 的 y 次幂 a**b 为 10 的 21 次方
// 取整除 - 返回商的整数部分 9//2 输出结果 4 , 9.0//2.0 输出结果 4.0

比较运算符

运算符 描述 实例
== 等于 - 比较对象是否相等 (a == b) 返回 False。
!= 不等于 - 比较两个对象是否不相等 (a != b) 返回 True.
> 大于 - 返回 x 是否大于 y (a > b) 返回 False。
< 小于 - 返回 x 是否小于 y。所有比较运算符返回 1 表示真,返回 0 表示假。这分别与特殊的变量 True 和 False 等价。注意,这些变量名的大写。 (a < b) 返回 True。
>= 大于等于 - 返回 x 是否大于等于 y。 (a >= b) 返回 False。
<= 小于等于 - 返回 x 是否小于等于 y。 (a <= b) 返回 True。

赋值运算符

运算符 描述 实例
= 简单的赋值运算符 c = a + b 将 a + b 的运算结果赋值为 c
+= 加法赋值运算符 c += a 等效于 c = c + a
-= 减法赋值运算符 c -= a 等效于 c = c - a
*= 乘法赋值运算符 c _= a 等效于 c = c _ a
/= 除法赋值运算符 c /= a 等效于 c = c / a
%= 取模赋值运算符 c %= a 等效于 c = c % a
**= 幂赋值运算符 c **= a 等效于 c = c ** a
//= 取整除赋值运算符 c //= a 等效于 c = c // a

位运算符

运算符 描述 实例
& 按位与运算符:参与运算的两个值,如果两个相应位都为 1,则该位的结果为 1,否则为 0 (a & b) 输出结果 12 ,二进制解释: 0000 1100
| 按位或运算符:只要对应的二个二进位有一个为 1 时,结果位就为 1。 (a | b) 输出结果 61 ,二进制解释: 0011 1101
^ 按位异或运算符:当两对应的二进位相异时,结果为 1 (a ^ b) 输出结果 49 ,二进制解释: 0011 0001
~ 按位取反运算符:对数据的每个二进制位取反,即把 1 变为 0,把 0 变为 1 (~a ) 输出结果 -61 ,二进制解释: 1100 0011, 在一个有符号二进制数的补码形式。
<< 左移动运算符:运算数的各二进位全部左移若干位,由”<<”右边的数指定移动的位数,高位丢弃,低位补 0。 a << 2 输出结果 240 ,二进制解释: 1111 0000
>> 右移动运算符:把”>>”左边的运算数的各二进位全部右移若干位,”>>”右边的数指定移动的位数 a >> 2 输出结果 15 ,二进制解释: 0000 1111

逻辑运算符

运算符 逻辑表达式 描述 实例
and x and y 布尔”与” - 如果 x 为 False,x and y 返回 False,否则它返回 y 的计算值。 (a and b) 返回 20。
or x or y 布尔”或” - 如果 x 是 True,它返回 x 的值,否则它返回 y 的计算值。 (a or b) 返回 10。
not not x 布尔”非” - 如果 x 为 True,返回 False 。如果 x 为 False,它返回 True。 not(a and b) 返回 False

成员运算符

运算符 描述 实例
in 如果在指定的序列中找到值返回 True,否则返回 False。 x 在 y 序列中 , 如果 x 在 y 序列中返回 True。
not in 如果在指定的序列中没有找到值返回 True,否则返回 False。 x 不在 y 序列中 , 如果 x 不在 y 序列中返回 True。

身份运算符

运算符 描述 实例
is is 是判断两个标识符是不是引用自一个对象 x is y, 如果 id(x) 等于 id(y) , is 返回结果 1
is not is not 是判断两个标识符是不是引用自不同对象 x is not y, 如果 id(x) 不等于 id(y). is not 返回结果 1

运算符优先级

运算符 描述
** 指数 (最高优先级)
~ + - 按位翻转, 一元加号和减号 (最后两个的方法名为 +@ 和 -@)
* / % // 乘,除,取模和取整除
+ - 加法减法
>> << 右移,左移运算符
& 位 ‘AND’
^ | 位运算符
<= < > >= 比较运算符
<> == != 等于运算符
= %= /= //= -= += *= **= 赋值运算符
is is not 身份运算符
in not in 成员运算符
not or and 逻辑运算符

控制语句

条件语句

1
2
3
4
5
6
if condition_1:
statement_block_1
elif condition_2:
statement_block_2
else:
statement_block_3

循环语句

while

1
2
while 判断条件:
statements

for

1
2
for <variable> in <sequence>:
<statements>

range()

1
2
for i in range(0, 10, 3) :
print(i)

break 和 continue

  • break 语句可以跳出 for 和 while 的循环体。
  • continue 语句被用来告诉 Python 跳过当前循环块中的剩余语句,然后继续进行下一轮循环。

pass

pass 语句什么都不做。它只在语法上需要一条语句但程序不需要任何操作时使用.例如:

1
2
while True:
pass # 等待键盘中断 (Ctrl+C)

函数

Python 定义函数使用 def 关键字,一般格式如下:

1
2
def  函数名(参数列表):
函数体

函数变量作用域

1
2
3
4
5
6
7
8
9
10
11
#!/usr/bin/env python3
a = 4 # 全局变量

def print_func1():
a = 17 # 局部变量
print("in print_func a = ", a)
def print_func2():
print("in print_func a = ", a)
print_func1()
print_func2()
print("a = ", a)

以上实例运行结果如下:

1
2
3
in print_func a =  17
in print_func a = 4
a = 4

关键字参数

函数也可以使用 kwarg=value 的关键字参数形式被调用.例如,以下函数:

1
2
3
4
5
def parrot(voltage, state='a stiff', action='voom', type='Norwegian Blue'):
print("-- This parrot wouldn't", action, end=' ')
print("if you put", voltage, "volts through it.")
print("-- Lovely plumage, the", type)
print("-- It's", state, "!")

可以以下几种方式被调用:

1
2
3
4
5
6
parrot(1000)                                          # 1 positional argument
parrot(voltage=1000) # 1 keyword argument
parrot(voltage=1000000, action='VOOOOOM') # 2 keyword arguments
parrot(action='VOOOOOM', voltage=1000000) # 2 keyword arguments
parrot('a million', 'bereft of life', 'jump') # 3 positional arguments
parrot('a thousand', state='pushing up the daisies') # 1 positional, 1 keyword

以下为错误调用方法:

1
2
3
4
parrot()                     # required argument missing
parrot(voltage=5.0, 'dead') # non-keyword argument after a keyword argument
parrot(110, voltage=220) # duplicate value for the same argument
parrot(actor='John Cleese') # unknown keyword argument

可变参数列表

最后,一个最不常用的选择是可以让函数调用可变个数的参数.这些参数被包装进一个元组(查看元组和序列).在这些可变个数的参数之前,可以有零到多个普通的参数:

1
2
3
4
5
def arithmetic_mean(*args):
sum = 0
for x in args:
sum += x
return sum

返回值

Python 的函数的返回值使用 return 语句,可以将函数作为一个值赋值给指定变量:

1
2
3
def return_sum(x,y):
c = x + y
return c

异常

异常处理

try 语句按照如下方式工作;

  • 首先,执行 try 子句(在关键字 try 和关键字 except 之间的语句)
  • 如果没有异常发生,忽略 except 子句,try 子句执行后结束。
  • 如果在执行 try 子句的过程中发生了异常,那么 try 子句余下的部分将被忽略。如果异常的类型和 except 之后的名称相符,那么对应的 except 子句将被执行。最后执行 try 语句之后的代码。
  • 如果一个异常没有与任何的 except 匹配,那么这个异常将会传递给上层的 try 中。
  • 不管 try 子句里面有没有发生异常,finally 子句都会执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import sys

try:
f = open('myfile.txt')
s = f.readline()
i = int(s.strip())
except OSError as err:
print("OS error: {0}".format(err))
except ValueError:
print("Could not convert data to an integer.")
except:
print("Unexpected error:", sys.exc_info()[0])
raise
finally:
# 清理行为

抛出异常

Python 使用 raise 语句抛出一个指定的异常。例如:

1
2
3
4
>>> raise NameError('HiThere')
Traceback (most recent call last):
File "<stdin>", line 1, in ?
NameError: HiThere

自定义异常

可以通过创建一个新的 exception 类来拥有自己的异常。异常应该继承自 Exception 类,或者直接继承,或者间接继承。

当创建一个模块有可能抛出多种不同的异常时,一种通常的做法是为这个包建立一个基础异常类,然后基于这个基础类为不同的错误情况创建不同的子类:

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
29
30
class Error(Exception):
"""Base class for exceptions in this module."""
pass

class InputError(Error):
"""Exception raised for errors in the input.

Attributes:
expression -- input expression in which the error occurred
message -- explanation of the error
"""

def __init__(self, expression, message):
self.expression = expression
self.message = message

class TransitionError(Error):
"""Raised when an operation attempts a state transition that's not
allowed.

Attributes:
previous -- state at beginning of transition
next -- attempted new state
message -- explanation of why the specific transition is not allowed
"""

def __init__(self, previous, next, message):
self.previous = previous
self.next = next
self.message = message

大多数的异常的名字都以”Error”结尾,就跟标准的异常命名一样。

面向对象

面向对象技术简介

  • 类(Class): 用来描述具有相同的属性和方法的对象的集合。它定义了该集合中每个对象所共有的属性和方法。对象是类的实例。

  • 类变量:类变量在整个实例化的对象中是公用的。类变量定义在类中且在函数体之外。类变量通常不作为实例变量使用。

  • 数据成员:类变量或者实例变量用于处理类及其实例对象的相关的数据。

  • 方法重写:如果从父类继承的方法不能满足子类的需求,可以对其进行改写,这个过程叫方法的覆盖(override),也称为方法的重写。

  • 实例变量:定义在方法中的变量,只作用于当前实例的类。

  • 继承:即一个派生类(derived class)继承基类(base class)的字段和方法。继承也允许把一个派生类的对象作为一个基类对象对待。例如,有这样一个设计:一个 Dog 类型的对象派生自 Animal 类,这是模拟”是一个(is-a)”关系(例图,Dog 是一个 Animal)。

  • 实例化:创建一个类的实例,类的具体对象。

  • 方法:类中定义的函数。

  • 对象:通过类定义的数据结构实例。对象包括两个数据成员(类变量和实例变量)和方法。

类定义

语法格式如下:

1
2
3
4
5
6
class ClassName:
<statement-1>
.
.
.
<statement-N>

类实例化后,可以使用其属性,实际上,创建一个类之后,可以通过类名访问其属性。

类对象

类对象支持两种操作:属性引用和实例化。

属性引用使用和 Python 中所有的属性引用一样的标准语法:obj.name

类对象创建后,类命名空间中所有的命名都是有效属性名。所以如果类定义是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/usr/bin/python3

class MyClass:
"""一个简单的类实例"""
i = 12345
def f(self):
return 'hello world'

# 实例化类
x = MyClass()

# 访问类的属性和方法
print("MyClass 类的属性 i 为:", x.i)
print("MyClass 类的方法 f 输出为:", x.f())

实例化类:

1
2
3
# 实例化类
x = MyClass()
# 访问类的属性和方法

以上创建了一个新的类实例并将该对象赋给局部变量 x,x 为空的对象。

执行以上程序输出结果为:

1
2
MyClass 类的属性 i 为: 12345
MyClass 类的方法 f 输出为: hello world

很多类都倾向于将对象创建为有初始状态的。因此类可能会定义一个名为 init() 的特殊方法(构造方法),像下面这样:

1
2
def __init__(self):
self.data = []

类定义了 init() 方法的话,类的实例化操作会自动调用 init() 方法。所以在下例中,可以这样创建一个新的实例:

1
x = MyClass()

当然, init() 方法可以有参数,参数通过 init() 传递到类的实例化操作上。例如:

1
2
3
4
5
6
7
8
>>> class Complex:
... def __init__(self, realpart, imagpart):
... self.r = realpart
... self.i = imagpart
...
>>> x = Complex(3.0, -4.5)
>>> x.r, x.i
(3.0, -4.5)

类的方法

在类地内部,使用 def 关键字可以为类定义一个方法,与一般函数定义不同,类方法必须包含参数 self,且为第一个参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#!/usr/bin/python3

#类定义
class people:
#定义基本属性
name = ''
age = 0
#定义私有属性,私有属性在类外部无法直接进行访问
__weight = 0
#定义构造方法
def __init__(self,n,a,w):
self.name = n
self.age = a
self.__weight = w
def speak(self):
print("%s 说: 我 %d 岁。" %(self.name,self.age))

# 实例化类
p = people('W3Cschool',10,30)
p.speak()

执行以上程序输出结果为:

1
W3Cschool 说: 我 10 岁。

继承

Python 同样支持类的继承,如果一种语言不支持继承就,类就没有什么意义。派生类的定义如下所示:

1
2
3
4
5
6
class DerivedClassName(BaseClassName1):
<statement-1>
.
.
.
<statement-N>

需要注意圆括号中基类的顺序,若是基类中有相同的方法名,而在子类使用时未指定,python 从左至右搜索 即方法在子类中未找到时,从左到右查找基类中是否包含方法。

BaseClassName(示例中的基类名)必须与派生类定义在一个作用域内。除了类,还可以用表达式,基类定义在另一个模块中时这一点非常有用:

1
class DerivedClassName(modname.BaseClassName):

实例

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
29
30
31
32
#!/usr/bin/python3

#类定义
class people:
#定义基本属性
name = ''
age = 0
#定义私有属性,私有属性在类外部无法直接进行访问
__weight = 0
#定义构造方法
def __init__(self,n,a,w):
self.name = n
self.age = a
self.__weight = w
def speak(self):
print("%s 说: 我 %d 岁。" %(self.name,self.age))

#单继承示例
class student(people):
grade = ''
def __init__(self,n,a,w,g):
#调用父类的构函
people.__init__(self,n,a,w)
self.grade = g
#覆写父类的方法
def speak(self):
print("%s 说: 我 %d 岁了,我在读 %d 年级"%(self.name,self.age,self.grade))



s = student('ken',10,60,3)
s.speak()

执行以上程序输出结果为:

1
ken 说: 我 10 岁了,我在读 3 年级

多继承

Python 同样有限的支持多继承形式。多继承的类定义形如下例:

1
2
3
4
5
6
class DerivedClassName(Base1, Base2, Base3):
<statement-1>
.
.
.
<statement-N>

需要注意圆括号中父类的顺序,若是父类中有相同的方法名,而在子类使用时未指定,python 从左至右搜索 即方法在子类中未找到时,从左到右查找父类中是否包含方法。

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#!/usr/bin/python3

#类定义
class people:
#定义基本属性
name = ''
age = 0
#定义私有属性,私有属性在类外部无法直接进行访问
__weight = 0
#定义构造方法
def __init__(self,n,a,w):
self.name = n
self.age = a
self.__weight = w
def speak(self):
print("%s 说: 我 %d 岁。" %(self.name,self.age))

#单继承示例
class student(people):
grade = ''
def __init__(self,n,a,w,g):
#调用父类的构函
people.__init__(self,n,a,w)
self.grade = g
#覆写父类的方法
def speak(self):
print("%s 说: 我 %d 岁了,我在读 %d 年级"%(self.name,self.age,self.grade))

#另一个类,多重继承之前的准备
class speaker():
topic = ''
name = ''
def __init__(self,n,t):
self.name = n
self.topic = t
def speak(self):
print("我叫 %s,我是一个演说家,我演讲的主题是 %s"%(self.name,self.topic))

#多重继承
class sample(speaker,student):
a =''
def __init__(self,n,a,w,g,t):
student.__init__(self,n,a,w,g)
speaker.__init__(self,n,t)

test = sample("Tim",25,80,4,"Python")
test.speak() #方法名同,默认调用的是在括号中排前地父类的方法

执行以上程序输出结果为:

1
我叫 Tim,我是一个演说家,我演讲的主题是 Python

方法重写

如果你的父类方法的功能不能满足你的需求,你可以在子类重写你父类的方法,实例如下:

1
2
3
4
5
6
7
8
9
10
11
12
#!/usr/bin/python3

class Parent: # 定义父类
def myMethod(self):
print ('调用父类方法')

class Child(Parent): # 定义子类
def myMethod(self):
print ('调用子类方法')

c = Child() # 子类实例
c.myMethod() # 子类调用重写方法

执行以上程序输出结果为:

1
调用子类方法

类属性与方法

类的私有属性

__private_attrs:两个下划线开头,声明该属性为私有,不能在类地外部被使用或直接访问。在类内部的方法中使用时self.__private_attrs

类的方法

在类地内部,使用 def 关键字可以为类定义一个方法,与一般函数定义不同,类方法必须包含参数 self,且为第一个参数

类的私有方法

__private_method:两个下划线开头,声明该方法为私有方法,不能在类地外部调用。在类的内部调用 slef.__private_methods

实例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/python3

class JustCounter:
__secretCount = 0 # 私有变量
publicCount = 0 # 公开变量

def count(self):
self.__secretCount += 1
self.publicCount += 1
print (self.__secretCount)

counter = JustCounter()
counter.count()
counter.count()
print (counter.publicCount)
print (counter.__secretCount) # 报错,实例不能访问私有变量

执行以上程序输出结果为:

1
2
3
4
5
6
7
1
2
2
Traceback (most recent call last):
File "test.py", line 16, in <module>
print (counter.__secretCount) # 报错,实例不能访问私有变量
AttributeError: 'JustCounter' object has no attribute '__secretCount'

类的专有方法:

  • **init :** 构造函数,在生成对象时调用
  • **del :** 析构函数,释放对象时使用
  • **repr :** 打印,转换
  • **setitem :** 按照索引赋值
  • **getitem:** 按照索引获取值
  • **len:** 获得长度
  • **cmp:** 比较运算
  • **call:** 函数调用
  • **add:** 加运算
  • **sub:** 减运算
  • **mul:** 乘运算
  • **div:** 除运算
  • **mod:** 求余运算
  • **pow:** 乘方

运算符重载

Python 同样支持运算符重载,我么可以对类的专有方法进行重载,实例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/python3

class Vector:
def __init__(self, a, b):
self.a = a
self.b = b

def __str__(self):
return 'Vector (%d, %d)' % (self.a, self.b)

def __add__(self,other):
return Vector(self.a + other.a, self.b + other.b)

v1 = Vector(2,10)
v2 = Vector(5,-2)
print (v1 + v2)

以上代码执行结果如下所示:

1
Vector(7,8)

标准库概览

操作系统接口

os 模块提供了不少与操作系统相关联的函数。

1
2
3
4
5
6
>>> import os
>>> os.getcwd() # 返回当前的工作目录
'C:\\Python34'
>>> os.chdir('/server/accesslogs') # 修改当前的工作目录
>>> os.system('mkdir today') # 执行系统命令 mkdir
0

文件通配符

glob 模块提供了一个函数用于从目录通配符搜索中生成文件列表:

1
2
3
>>> import glob
>>> glob.glob('*.py')
['primes.py', 'random.py', 'quote.py']

命令行参数

通用工具脚本经常调用命令行参数。这些命令行参数以链表形式存储于 sys 模块的 argv 变量。例如在命令行中执行 python demo.py one two three 后可以得到以下输出结果:

1
2
3
>>> import sys
>>> print(sys.argv)
['demo.py', 'one', 'two', 'three']

错误输出重定向和程序终止

sys 还有 stdin,stdout 和 stderr 属性,即使在 stdout 被重定向时,后者也可以用于显示警告和错误信息。

1
2
>>> sys.stderr.write('Warning, log file not found starting a new one\n')
Warning, log file not found starting a new one

字符串正则匹配

re 模块为高级字符串处理提供了正则表达式工具。对于复杂的匹配和处理,正则表达式提供了简洁、优化的解决方案:

1
2
3
4
5
>>> import re
>>> re.findall(r'\bf[a-z]*', 'which foot or hand fell fastest')
['foot', 'fell', 'fastest']
>>> re.sub(r'(\b[a-z]+) \1', r'\1', 'cat in the the hat')
'cat in the hat'

数学

math 模块为浮点运算提供了对底层 C 函数库的访问:

1
2
3
4
5
>>> import math
>>> math.cos(math.pi / 4)
0.70710678118654757
>>> math.log(1024, 2)
10.0

参考资料

Java 容器之 List

ListCollection 的子接口,其中可以保存各个重复的内容。

List 简介

List 是一个接口,它继承于 Collection 的接口。它代表着有序的队列。

AbstractList 是一个抽象类,它继承于 AbstractCollectionAbstractList 实现了 List 接口中除 size()get(int location) 之外的函数。

AbstractSequentialList 是一个抽象类,它继承于 AbstractListAbstractSequentialList 实现了“链表中,根据 index 索引值操作链表的全部函数”。

ArrayList 和 LinkedList

ArrayListLinkedListList 最常用的实现。

  • ArrayList 基于动态数组实现,存在容量限制,当元素数超过最大容量时,会自动扩容;LinkedList 基于双向链表实现,不存在容量限制。
  • ArrayList 随机访问速度较快,随机插入、删除速度较慢;LinkedList 随机插入、删除速度较快,随机访问速度较慢。
  • ArrayListLinkedList 都不是线程安全的。

Vector 和 Stack

VectorStack 的设计目标是作为线程安全的 List 实现,替代 ArrayList

  • Vector - VectorArrayList 类似,也实现了 List 接口。但是, Vector 中的主要方法都是 synchronized 方法,即通过互斥同步方式保证操作的线程安全。
  • Stack - Stack 也是一个同步容器,它的方法也用 synchronized 进行了同步,它实际上是继承于 Vector 类。

ArrayList

ArrayList 从数据结构角度来看,可以视为支持动态扩容的线性表。

img

ArrayList 要点

ArrayList 是一个数组队列,相当于动态数组。**ArrayList 默认初始容量大小为 10 ,添加元素时,如果发现容量已满,会自动扩容为原始大小的 1.5 倍**。因此,应该尽量在初始化 ArrayList 时,为其指定合适的初始化容量大小,减少扩容操作产生的性能开销。

ArrayList 定义:

1
2
public class ArrayList<E> extends AbstractList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable

从 ArrayList 的定义,不难看出 ArrayList 的一些基本特性:

  • ArrayList 实现了 List 接口,并继承了 AbstractList,它支持所有 List 的操作。
  • ArrayList 实现了 RandomAccess 接口,支持随机访问RandomAccess 是一个标志接口,它意味着“只要实现该接口的 List 类,都支持快速随机访问”。在 ArrayList 中,我们即可以通过元素的序号快速获取元素对象;这就是快速随机访问。
  • ArrayList 实现了 Cloneable 接口,默认为浅拷贝
  • ArrayList 实现了 Serializable 接口,支持序列化,能通过序列化方式传输。
  • ArrayList非线程安全的。

ArrayList 原理

ArrayList 的数据结构

ArrayList 包含了两个重要的元素:elementDatasize

1
2
3
4
5
6
// 默认初始化容量
private static final int DEFAULT_CAPACITY = 10;
// 对象数组
transient Object[] elementData;
// 数组长度
private int size;
  • size - 是动态数组的实际大小。
  • elementData - 是一个 Object 数组,用于保存添加到 ArrayList 中的元素。

ArrayList 的序列化

ArrayList 具有动态扩容特性,因此保存元素的数组不一定都会被使用,那么就没必要全部进行序列化。为此,ArrayList 定制了其序列化方式。具体做法是:

  • 存储元素的 Object 数组(即 elementData)使用 transient 修饰,使得它可以被 Java 序列化所忽略。
  • ArrayList 重写了 writeObject()readObject() 来控制序列化数组中有元素填充那部分内容。

:bulb: 不了解 Java 序列化方式,可以参考:Java 序列化

ArrayList 构造方法

ArrayList 类实现了三个构造函数:

  • 第一个是默认构造方法,ArrayList 会创建一个空数组;
  • 第二个是创建 ArrayList 对象时,传入一个初始化值;
  • 第三个是传入一个集合类型进行初始化。

当 ArrayList 新增元素时,如果所存储的元素已经超过其当前容量,它会计算容量后再进行动态扩容。数组的动态扩容会导致整个数组进行一次内存复制。因此,初始化 ArrayList 时,指定数组初始大小,有助于减少数组的扩容次数,从而提高系统性能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public ArrayList() {
// 创建一个空数组
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
// 根据初始化值创建数组大小
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
// 初始化值为 0 时,创建一个空数组
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
}
}

ArrayList 访问元素

ArrayList 访问元素的实现主要基于以下关键性源码:

1
2
3
4
5
6
7
8
9
// 获取第 index 个元素
public E get(int index) {
rangeCheck(index);
return elementData(index);
}

E elementData(int index) {
return (E) elementData[index];
}

实现非常简单,其实就是**通过数组下标访问数组元素,其时间复杂度为 O(1)**,所以很快。

ArrayList 添加元素

ArrayList 添加元素有两种方法:一种是添加元素到数组末尾,另外一种是添加元素到任意位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 添加元素到数组末尾
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}

// 添加元素到任意位置
public void add(int index, E element) {
rangeCheckForAdd(index);

ensureCapacityInternal(size + 1); // Increments modCount!!
System.arraycopy(elementData, index, elementData, index + 1,
size - index);
elementData[index] = element;
size++;
}

两种添加元素方法的不同点是:

  • 添加元素到任意位置,会导致在该位置后的所有元素都需要重新排列
  • 而添加元素到数组末尾,在没有发生扩容的前提下,是不会有元素复制排序过程的。

两种添加元素方法的共同点是:添加元素时,会先检查容量大小,如果发现容量不足,会自动扩容为原始大小的 1.5 倍

ArrayList 添加元素的实现主要基于以下关键性源码:

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
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}

ensureExplicitCapacity(minCapacity);
}

private void ensureExplicitCapacity(int minCapacity) {
modCount++;

// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}

private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}

ArrayList 执行添加元素动作(add 方法)时,调用 ensureCapacityInternal 方法来保证容量足够。

  • 如果容量足够时,将数据作为数组中 size+1 位置上的元素写入,并将 size 自增 1。
  • 如果容量不够时,需要使用 grow 方法进行扩容数组,新容量的大小为 oldCapacity + (oldCapacity >> 1),也就是旧容量的 1.5 倍。扩容操作实际上是调用 Arrays.copyOf() 把原数组拷贝为一个新数组,因此最好在创建 ArrayList 对象时就指定大概的容量大小,减少扩容操作的次数。

ArrayList 删除元素

ArrayList 的删除方法和添加元素到任意位置方法有些相似。

ArrayList 在每一次有效的删除操作后,都要进行数组的重组,并且删除的元素位置越靠前,数组重组的开销就越大。具体来说,ArrayList 会**调用 System.arraycopy()index+1 后面的元素都复制到 index 位置上。

1
2
3
4
5
6
7
8
9
10
11
12
13
public E remove(int index) {
rangeCheck(index);

modCount++;
E oldValue = elementData(index);

int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index, numMoved);
elementData[--size] = null; // clear to let GC do its work

return oldValue;
}

ArrayList 的 Fail-Fast

ArrayList 使用 modCount 来记录结构发生变化的次数。结构发生变化是指添加或者删除至少一个元素的所有操作,或者是调整内部数组的大小,仅仅只是设置元素的值不算结构发生变化。

在进行序列化或者迭代等操作时,需要比较操作前后 modCount 是否改变,如果发生改变,ArrayList 会抛出 ConcurrentModificationException

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException{
// Write out element count, and any hidden stuff
int expectedModCount = modCount;
s.defaultWriteObject();

// Write out size as capacity for behavioural compatibility with clone()
s.writeInt(size);

// Write out all elements in the proper order.
for (int i=0; i<size; i++) {
s.writeObject(elementData[i]);
}

if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}

LinkedList

LinkedList 从数据结构角度来看,可以视为双链表。

img

LinkedList 要点

LinkedList 基于双链表结构实现。由于是双链表,所以顺序访问会非常高效,而随机访问效率比较低。

LinkedList 定义:

1
2
3
public class LinkedList<E>
extends AbstractSequentialList<E>
implements List<E>, Deque<E>, Cloneable, java.io.Serializable

LinkedList 的定义,可以得出 LinkedList 的一些基本特性:

  • LinkedList 实现了 List 接口,并继承了 AbstractSequentialList ,它支持所有 List 的操作。
  • LinkedList 实现了 Deque 接口,也可以被当作队列(Queue)或双端队列(Deque)进行操作,此外,也可以用来实现栈。
  • LinkedList 实现了 Cloneable 接口,默认为浅拷贝
  • LinkedList 实现了 Serializable 接口,支持序列化
  • LinkedList非线程安全的。

LinkedList 原理

LinkedList 的数据结构

LinkedList 内部维护了一个双链表

LinkedList 通过 Node 类型的头尾指针(firstlast)来访问数据。

1
2
3
4
5
6
// 链表长度
transient int size = 0;
// 链表头节点
transient Node<E> first;
// 链表尾节点
transient Node<E> last;
  • size - 表示双链表中节点的个数,初始为 0
  • firstlast - 分别是双链表的头节点和尾节点

NodeLinkedList 的内部类,它表示链表中的元素实例。Node 中包含三个元素:

  • prev 是该节点的上一个节点;
  • next 是该节点的下一个节点;
  • item 是该节点所包含的值。
1
2
3
4
5
6
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
...
}

LinkedList 的序列化

LinkedListArrayList 一样也定制了自身的序列化方式。具体做法是:

  • size (双链表容量大小)、firstlast (双链表的头尾节点)修饰为 transient,使得它们可以被 Java 序列化所忽略。
  • 重写了 writeObject()readObject() 来控制序列化时,只处理双链表中能被头节点链式引用的节点元素。

LinkedList 访问元素

LinkedList 访问元素的实现主要基于以下关键性源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public E get(int index) {
checkElementIndex(index);
return node(index).item;
}

Node<E> node(int index) {
// assert isElementIndex(index);

if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}

获取 LinkedList 第 index 个元素的算法是:

  • 判断 index 在链表前半部分,还是后半部分。
  • 如果是前半部分,从头节点开始查找;如果是后半部分,从尾结点开始查找。

LinkedList 这种访问元素的性能是 O(N) 级别的(极端情况下,扫描 N/2 个元素);相比于 ArrayListO(1),显然要慢不少。

推荐使用迭代器遍历 LinkedList ,不要使用传统的 for 循环。注:foreach 语法会被编译器转换成迭代器遍历,但是它的遍历过程中不允许修改 List 长度,即不能进行增删操作。

LinkedList 添加元素

LinkedList 有多种添加元素方法:

  • add(E e):默认添加元素方法(插入尾部)
  • add(int index, E element):添加元素到任意位置
  • addFirst(E e):在头部添加元素
  • addLast(E e):在尾部添加元素
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public boolean add(E e) {
linkLast(e);
return true;
}

public void add(int index, E element) {
checkPositionIndex(index);

if (index == size)
linkLast(element);
else
linkBefore(element, node(index));
}

public void addFirst(E e) {
linkFirst(e);
}

public void addLast(E e) {
linkLast(e);
}

LinkedList 添加元素的实现主要基于以下关键性源码:

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
29
30
31
32
33
34
35
36
private void linkFirst(E e) {
final Node<E> f = first;
final Node<E> newNode = new Node<>(null, e, f);
first = newNode;
if (f == null)
last = newNode;
else
f.prev = newNode;
size++;
modCount++;
}

void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}

void linkBefore(E e, Node<E> succ) {
// assert succ != null;
final Node<E> pred = succ.prev;
final Node<E> newNode = new Node<>(pred, e, succ);
succ.prev = newNode;
if (pred == null)
first = newNode;
else
pred.next = newNode;
size++;
modCount++;
}

算法如下:

  • 将新添加的数据包装为 Node
  • 如果往头部添加元素,将头指针 first 指向新的 Node,之前的 first 对象的 prev 指向新的 Node
  • 如果是向尾部添加元素,则将尾指针 last 指向新的 Node,之前的 last 对象的 next 指向新的 Node

LinkedList 删除元素

LinkedList 删除元素的实现主要基于以下关键性源码:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public boolean remove(Object o) {
if (o == null) {
// 遍历找到要删除的元素节点
for (Node<E> x = first; x != null; x = x.next) {
if (x.item == null) {
unlink(x);
return true;
}
}
} else {
// 遍历找到要删除的元素节点
for (Node<E> x = first; x != null; x = x.next) {
if (o.equals(x.item)) {
unlink(x);
return true;
}
}
}
return false;
}

E unlink(Node<E> x) {
// assert x != null;
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;

if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
}

if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}

x.item = null;
size--;
modCount++;
return element;
}

算法说明:

  • 遍历找到要删除的元素节点,然后调用 unlink 方法删除节点;
  • unlink 删除节点的方法:
    • 如果当前节点有前驱节点,则让前驱节点指向当前节点的下一个节点;否则,让双链表头指针指向下一个节点。
    • 如果当前节点有后继节点,则让后继节点指向当前节点的前一个节点;否则,让双链表尾指针指向上一个节点。

List 常见问题

Arrays.asList 问题点

在业务开发中,我们常常会把原始的数组转换为 List 类数据结构,来继续展开各种 Stream 操作。通常,我们会使用 Arrays.asList 方法可以把数组一键转换为 List

【示例】Arrays.asList 转换基本类型数组

1
2
3
int[] arr = { 1, 2, 3 };
List list = Arrays.asList(arr);
log.info("list:{} size:{} class:{}", list, list.size(), list.get(0).getClass());

【输出】

1
11:26:33.214 [main] INFO io.github.dunwu.javacore.container.list.AsList示例 - list:[[I@ae45eb6] size:1 class:class [I

数组元素个数为 3,但转换后的列表个数为 1。

由此可知, Arrays.asList 第一个问题点:不能直接使用 Arrays.asList 来转换基本类型数组

其原因是:Arrays.asList 方法传入的是一个泛型 T 类型可变参数,最终 int 数组整体作为了一个对象成为了泛型类型 T:

1
2
3
public static <T> List<T> asList(T... a) {
return new ArrayList<>(a);
}

直接遍历这样的 List 必然会出现 Bug,修复方式有两种,如果使用 Java8 以上版本可以使用 Arrays.stream 方法来转换,否则可以把 int 数组声明为包装类型 Integer 数组:

【示例】转换整型数组为 List 的正确方式

1
2
3
4
5
6
7
int[] arr1 = { 1, 2, 3 };
List list1 = Arrays.stream(arr1).boxed().collect(Collectors.toList());
log.info("list:{} size:{} class:{}", list1, list1.size(), list1.get(0).getClass());

Integer[] arr2 = { 1, 2, 3 };
List list2 = Arrays.asList(arr2);
log.info("list:{} size:{} class:{}", list2, list2.size(), list2.get(0).getClass());

【示例】Arrays.asList 转换引用类型数组

1
2
3
4
5
6
7
8
9
String[] arr = { "1", "2", "3" };
List list = Arrays.asList(arr);
arr[1] = "4";
try {
list.add("5");
} catch (Exception ex) {
ex.printStackTrace();
}
log.info("arr:{} list:{}", Arrays.toString(arr), list);

抛出 java.lang.UnsupportedOperationException

抛出异常的原因在于 Arrays.asList 第二个问题点:**Arrays.asList 返回的 List 不支持增删操作**。Arrays.asList 返回的 List 并不是我们期望的 java.util.ArrayList,而是 Arrays 的内部类 ArrayList

查看源码,我们可以发现 Arrays.asList 返回的 ArrayList 继承了 AbstractList,但是并没有覆写 addremove 方法。

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
29
30
private static class ArrayList<E> extends AbstractList<E>
implements RandomAccess, java.io.Serializable
{
private static final long serialVersionUID = -2764017481108945198L;
private final E[] a;

ArrayList(E[] array) {
a = Objects.requireNonNull(array);
}

// ...

@Override
public E set(int index, E element) {
E oldValue = a[index];
a[index] = element;
return oldValue;
}

}

public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E> {
public void add(int index, E element) {
throw new UnsupportedOperationException();
}

public E remove(int index) {
throw new UnsupportedOperationException();
}
}

Arrays.asList 第三个问题点:**对原始数组的修改会影响到我们获得的那个 List**。ArrayList 其实是直接使用了原始的数组。

解决方法很简单,重新 new 一个 ArrayList 初始化 Arrays.asList 返回的 List 即可:

1
2
3
4
5
6
7
8
9
String[] arr = { "1", "2", "3" };
List list = new ArrayList(Arrays.asList(arr));
arr[1] = "4";
try {
list.add("5");
} catch (Exception ex) {
ex.printStackTrace();
}
log.info("arr:{} list:{}", Arrays.toString(arr), list);

List.subList 问题点

List.subList 直接引用了原始的 List,也可以认为是共享“存储”,而且对原始 List 直接进行结构性修改会导致 SubList 出现异常。

1
2
3
4
5
6
7
8
private static List<List<Integer>> data = new ArrayList<>();

private static void oom() {
for (int i = 0; i < 1000; i++) {
List<Integer> rawList = IntStream.rangeClosed(1, 100000).boxed().collect(Collectors.toList());
data.add(rawList.subList(0, 1));
}
}

出现 OOM 的原因是,循环中的 1000 个具有 10 万个元素的 List 始终得不到回收,因为它始终被 subList 方法返回的 List 强引用。

解决方法是:

1
2
3
4
5
6
private static void oomfix() {
for (int i = 0; i < 1000; i++) {
List<Integer> rawList = IntStream.rangeClosed(1, 100000).boxed().collect(Collectors.toList());
data.add(new ArrayList<>(rawList.subList(0, 1)));
}
}

【示例】子 List 强引用原始的 List

1
2
3
4
5
6
7
8
9
10
11
12
13
private static void wrong() {
List<Integer> list = IntStream.rangeClosed(1, 10).boxed().collect(Collectors.toList());
List<Integer> subList = list.subList(1, 4);
System.out.println(subList);
subList.remove(1);
System.out.println(list);
list.add(0);
try {
subList.forEach(System.out::println);
} catch (Exception ex) {
ex.printStackTrace();
}
}

抛出 java.util.ConcurrentModificationException

解决方法:

一种是,不直接使用 subList 方法返回的 SubList,而是重新使用 new ArrayList,在构造方法传入 SubList,来构建一个独立的 ArrayList;

另一种是,对于 Java 8 使用 Stream 的 skip 和 limit API 来跳过流中的元素,以及限制流中元素的个数,同样可以达到 SubList 切片的目的。

1
2
3
4
//方式一:
List<Integer> subList = new ArrayList<>(list.subList(1, 4));
//方式二:
List<Integer> subList = list.stream().skip(1).limit(3).collect(Collectors.toList());

参考资料

SQL 语法速成

本文针对关系型数据库的基本语法。限于篇幅,本文侧重说明用法,不会展开讲解特性、原理。

本文语法主要针对 Mysql,但大部分的语法对其他关系型数据库也适用。

SQL 简介

数据库术语

  • 数据库(database) - 保存有组织的数据的容器(通常是一个文件或一组文件)。
  • 数据表(table) - 某种特定类型数据的结构化清单。
  • 模式(schema) - 关于数据库和表的布局及特性的信息。模式定义了数据在表中如何存储,包含存储什么样的数据,数据如何分解,各部分信息如何命名等信息。数据库和表都有模式。
  • 行(row) - 表中的一条记录。
  • 列(column) - 表中的一个字段。所有表都是由一个或多个列组成的。
  • 主键(primary key) - 一列(或一组列),其值能够唯一标识表中每一行。

SQL 语法

SQL(Structured Query Language),标准 SQL 由 ANSI 标准委员会管理,从而称为 ANSI SQL。各个 DBMS 都有自己的实现,如 PL/SQL、Transact-SQL 等。

SQL 语法结构

img

SQL 语法结构包括:

  • 子句 - 是语句和查询的组成成分。(在某些情况下,这些都是可选的。)
  • 表达式 - 可以产生任何标量值,或由列和行的数据库表
  • 谓词 - 给需要评估的 SQL 三值逻辑(3VL)(true/false/unknown)或布尔真值指定条件,并限制语句和查询的效果,或改变程序流程。
  • 查询 - 基于特定条件检索数据。这是 SQL 的一个重要组成部分。
  • 语句 - 可以持久地影响纲要和数据,也可以控制数据库事务、程序流程、连接、会话或诊断。

SQL 语法要点

  • SQL 语句不区分大小写,但是数据库表名、列名和值是否区分,依赖于具体的 DBMS 以及配置。

例如:SELECTselectSelect 是相同的。

  • 多条 SQL 语句必须以分号(;)分隔

  • 处理 SQL 语句时,所有空格都被忽略。SQL 语句可以写成一行,也可以分写为多行。

1
2
3
4
5
6
7
-- 一行 SQL 语句
UPDATE user SET username='robot', password='robot' WHERE username = 'root';

-- 多行 SQL 语句
UPDATE user
SET username='robot', password='robot'
WHERE username = 'root';
  • SQL 支持三种注释
1
2
3
## 注释1
-- 注释2
/* 注释3 */

SQL 分类

DDL

DDL,英文叫做 Data Definition Language,即“数据定义语言”DDL 用于定义数据库对象

DDL 定义操作包括创建(CREATE)、删除(DROP)、修改(ALTER);而被操作的对象包括:数据库、数据表和列、视图、索引。

DML

DML,英文叫做 Data Manipulation Language,即“数据操作语言”DML 用于访问数据库的数据

DML 访问操作包括插入(INSERT)、删除(DELETE)、修改(UPDATE)、查询(SELECT)。这四个指令合称 CRUD,英文单词为 Create, Read, Update, Delete,即增删改查。

TCL

TCL,英文叫做 Transaction Control Language,即“事务控制语言”TCL 用于管理数据库中的事务,实际上就是用于管理由 DML 语句所产生的数据变更,它还允许将语句分组为逻辑事务。

TCL 的核心指令是 COMMITROLLBACK

DCL

DCL,英文叫做 Data Control Language,即“数据控制语言”。DCL 用于对数据访问权进行控制,它可以控制特定用户账户对数据表、查看表、预存程序、用户自定义函数等数据库对象的控制权。

DCL 的核心指令是 GRANTREVOKE

DCL 以控制用户的访问权限为主,因此其指令作法并不复杂,可利用 DCL 控制的权限有:CONNECTSELECTINSERTUPDATEDELETEEXECUTEUSAGEREFERENCES。根据不同的 DBMS 以及不同的安全性实体,其支持的权限控制也有所不同。

数据定义(CREATE、ALTER、DROP)

DDL 的主要功能是定义数据库对象(如:数据库、数据表、视图、索引等)。

数据库(DATABASE)

创建数据库

1
CREATE DATABASE IF NOT EXISTS db_tutorial;

删除数据库

1
DROP DATABASE IF EXISTS db_tutorial;

选择数据库

1
USE db_tutorial;

数据表(TABLE)

创建数据表

普通创建

1
2
3
4
5
6
CREATE TABLE user (
id INT(10) UNSIGNED NOT NULL COMMENT 'Id',
username VARCHAR(64) NOT NULL DEFAULT 'default' COMMENT '用户名',
password VARCHAR(64) NOT NULL DEFAULT 'default' COMMENT '密码',
email VARCHAR(64) NOT NULL DEFAULT 'default' COMMENT '邮箱'
) COMMENT ='用户表';

根据已有的表创建新表

1
2
CREATE TABLE vip_user AS
SELECT * FROM user;

修改数据表

添加列
1
2
ALTER TABLE user
ADD age int(3);
删除列
1
2
ALTER TABLE user
DROP COLUMN age;
修改列
1
2
ALTER TABLE `user`
MODIFY COLUMN age tinyint;

删除数据表

1
2
DROP TABLE IF EXISTS user;
DROP TABLE IF EXISTS vip_user;

视图(VIEW)

“视图”是基于 SQL 语句的结果集的可视化的表。视图是虚拟的表,本身不存储数据,也就不能对其进行索引操作。对视图的操作和对普通表的操作一样。

视图的作用:

  • 简化复杂的 SQL 操作,比如复杂的连接。
  • 只使用实际表的一部分数据。
  • 通过只给用户访问视图的权限,保证数据的安全性。
  • 更改数据格式和表示。

创建视图

1
2
3
CREATE VIEW top_10_user_view AS
SELECT id, username FROM user
WHERE id < 10;

删除视图

1
DROP VIEW top_10_user_view;

索引(INDEX)

“索引”是数据库为了提高查找效率的一种数据结构

日常生活中,我们可以通过检索目录,来快速定位书本中的内容。索引和数据表,就好比目录和书,想要高效查询数据表,索引至关重要。在数据量小且负载较低时,不恰当的索引对于性能的影响可能还不明显;但随着数据量逐渐增大,性能则会急剧下降。因此,设置合理的索引是数据库查询性能优化的最有效手段

更新一个包含索引的表需要比更新一个没有索引的表花费更多的时间,这是由于索引本身也需要更新。因此,理想的做法是仅仅在常常被搜索的列(以及表)上面创建索引。

“唯一索引”表明此索引的每一个索引值只对应唯一的数据记录。

创建索引

1
2
CREATE INDEX idx_email
ON user(email);

创建唯一索引

1
2
CREATE UNIQUE INDEX uniq_name
ON user(name);

删除索引

1
2
3
4
ALTER TABLE user
DROP INDEX idx_email;
ALTER TABLE user
DROP INDEX uniq_name;

添加主键

1
2
ALTER TABLE user
ADD PRIMARY KEY (id);

删除主键

1
2
ALTER TABLE user
DROP PRIMARY KEY;

约束

SQL 约束用于规定表中的数据规则。

如果存在违反约束的数据行为,行为会被约束终止。约束可以在创建表时规定(通过 CREATE TABLE 语句),或者在表创建之后规定(通过 ALTER TABLE 语句)。

约束类型

  • NOT NULL - 指示字段不能存储 NULL 值。
  • UNIQUE - 保证字段的每行必须有唯一的值。
  • PRIMARY KEY - PRIMARY KEY 的作用是唯一标识一条记录,不能重复,不能为空,即相当于 NOT NULL + UNIQUE。确保字段(或两个列多个列的结合)有唯一标识,有助于更容易更快速地找到表中的一个特定的记录。
  • FOREIGN KEY - 保证一个表中的数据匹配另一个表中的值的参照完整性。
  • CHECK - 用于检查字段取值范围的有效性。
  • DEFAULT - 表明字段的默认值。如果插入数据时,该字段没有赋值,就会被设置为默认值。

创建表时使用约束条件:

1
2
3
4
5
6
7
8
CREATE TABLE Users (
Id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增Id',
Username VARCHAR(64) NOT NULL UNIQUE DEFAULT 'default' COMMENT '用户名',
Password VARCHAR(64) NOT NULL DEFAULT 'default' COMMENT '密码',
Email VARCHAR(64) NOT NULL DEFAULT 'default' COMMENT '邮箱地址',
Enabled TINYINT(4) DEFAULT NULL COMMENT '是否有效',
PRIMARY KEY (Id)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

增删改查(CRUD)

增删改查,又称为 **CRUD**,是数据库基本操作中的基本操作。

插入数据

  • INSERT INTO 语句用于向表中插入新记录。

插入完整的行

1
2
INSERT INTO user
VALUES (10, 'root', 'root', 'xxxx@163.com');

插入行的一部分

1
2
INSERT INTO user(username, password, email)
VALUES ('admin', 'admin', 'xxxx@163.com');

插入查询出来的数据

1
2
3
INSERT INTO user(username)
SELECT name
FROM account;

更新数据

  • UPDATE 语句用于更新表中的记录。
1
2
3
UPDATE user
SET username='robot', password='robot'
WHERE username = 'root';

删除数据

  • DELETE 语句用于删除表中的记录。
  • TRUNCATE TABLE 可以清空表,也就是删除所有行。

删除表中的指定数据

1
DELETE FROM user WHERE username = 'robot';

清空表中的数据

1
TRUNCATE TABLE user;

查询数据

  • SELECT 语句用于从数据库中查询数据。
  • DISTINCT 用于返回唯一不同的值。它作用于所有列,也就是说所有列的值都相同才算相同。
  • LIMIT 限制返回的行数。可以有两个参数,第一个参数为起始行,从 0 开始;第二个参数为返回的总行数。
    • ASC :升序(默认)
    • DESC :降序

查询单列

1
SELECT prod_name FROM products;

查询多列

1
SELECT prod_id, prod_name, prod_price FROM products;

查询所有列

1
SELECT * FROM products;

查询不同的值

1
SELECT DISTINCT vend_id FROM products;

限制查询数量

1
2
3
4
5
-- 返回前 5 行
SELECT * FROM products LIMIT 5;
SELECT * FROM products LIMIT 0, 5;
-- 返回第 3 ~ 5 行
SELECT * FROM products LIMIT 2, 3;

过滤数据(WHERE)

子查询是嵌套在较大查询中的 SQL 查询。子查询也称为内部查询内部选择,而包含子查询的语句也称为外部查询外部选择

  • 子查询可以嵌套在 SELECTINSERTUPDATEDELETE 语句内或另一个子查询中。

  • 子查询通常会在另一个 SELECT 语句的 WHERE 子句中添加。

  • 您可以使用比较运算符,如 ><,或 =。比较运算符也可以是多行运算符,如 INANYALL

  • 子查询必须被圆括号 () 括起来。

  • 内部查询首先在其父查询之前执行,以便可以将内部查询的结果传递给外部查询。执行过程可以参考下图:

    sql-subqueries

子查询的子查询

1
2
3
4
5
6
7
SELECT cust_name, cust_contact
FROM customers
WHERE cust_id IN (SELECT cust_id
FROM orders
WHERE order_num IN (SELECT order_num
FROM orderitems
WHERE prod_id = 'RGAN01'));

WHERE 子句

在 SQL 语句中,数据根据 WHERE 子句中指定的搜索条件进行过滤。

WHERE 子句的基本格式如下:

1
SELECT ……(列名) FROM ……(表名) WHERE ……(子句条件)

WHERE 子句用于过滤记录,即缩小访问数据的范围。WHERE 后跟一个返回 truefalse 的条件。

WHERE 可以与 SELECTUPDATEDELETE 一起使用。

SELECT 语句中的 WHERE 子句

1
2
SELECT * FROM Customers
WHERE cust_name = 'Kids Place';

UPDATE 语句中的 WHERE 子句

1
2
3
UPDATE Customers
SET cust_name = 'Jack Jones'
WHERE cust_name = 'Kids Place';

DELETE 语句中的 WHERE 子句

1
2
DELETE FROM Customers
WHERE cust_name = 'Kids Place';

可以在 WHERE 子句中使用的操作符:

比较操作符

运算符 描述
= 等于
<> 不等于。注释:在 SQL 的一些版本中,该操作符可被写成 !=
> 大于
< 小于
>= 大于等于
<= 小于等于

范围操作符

运算符 描述
BETWEEN 在某个范围内
IN 指定针对某个列的多个可能值
  • IN 操作符在 WHERE 子句中使用,作用是在指定的几个特定值中任选一个值。

  • BETWEEN 操作符在 WHERE 子句中使用,作用是选取介于某个范围内的值。

IN 示例

1
2
3
SELECT *
FROM products
WHERE vend_id IN ('DLL01', 'BRS01');

BETWEEN 示例

1
2
3
SELECT *
FROM products
WHERE prod_price BETWEEN 3 AND 5;

逻辑操作符

运算符 描述
AND 并且(与)
OR 或者(或)
NOT 否定(非)

ANDORNOT 是用于对过滤条件的逻辑处理指令。

  • AND 优先级高于 OR,为了明确处理顺序,可以使用 ()AND 操作符表示左右条件都要满足。

  • OR 操作符表示左右条件满足任意一个即可。

  • NOT 操作符用于否定一个条件。

AND 示例

1
2
3
SELECT prod_id, prod_name, prod_price
FROM products
WHERE vend_id = 'DLL01' AND prod_price <= 4;

OR 示例

1
2
3
SELECT prod_id, prod_name, prod_price
FROM products
WHERE vend_id = 'DLL01' OR vend_id = 'BRS01';

NOT 示例

1
2
3
SELECT *
FROM products
WHERE prod_price NOT BETWEEN 3 AND 5;

通配符

运算符 描述
LIKE 搜索某种模式
% 表示任意字符出现任意次数
_ 表示任意字符出现一次
[] 必须匹配指定位置的一个字符

LIKE 操作符在 WHERE 子句中使用,作用是确定字符串是否匹配模式。只有字段是文本值时才使用 LIKE

LIKE 支持以下通配符匹配选项:

  • % 表示任何字符出现任意次数。
  • _ 表示任何字符出现一次。
  • [] 必须匹配指定位置的一个字符。

注意:不要滥用通配符,通配符位于开头处匹配会非常慢

% 示例:

1
2
3
SELECT prod_id, prod_name, prod_price
FROM products
WHERE prod_name LIKE '%bean bag%';

_ 示例:

1
2
3
SELECT prod_id, prod_name, prod_price
FROM products
WHERE prod_name LIKE '__ inch teddy bear';

排序和分组

ORDER BY

ORDER BY 用于对结果集进行排序。

ORDER BY 有两种排序模式:

  • ASC :升序(默认)
  • DESC :降序

可以按多个列进行排序,并且为每个列指定不同的排序方式。

指定多个列的排序示例:

1
2
SELECT * FROM products
ORDER BY prod_price DESC, prod_name ASC;

GROUP BY

GROUP BY 子句将记录分组到汇总行中,GROUP BY 为每个组返回一个记录。

GROUP BY 可以按一列或多列进行分组。

GROUP BY 通常还涉及聚合函数:COUNT,MAX,SUM,AVG 等。

GROUP BY 按分组字段进行排序后,ORDER BY 可以以汇总字段来进行排序。

分组示例:

1
2
SELECT cust_name, COUNT(cust_address) AS addr_num
FROM Customers GROUP BY cust_name;

分组后排序示例:

1
2
3
SELECT cust_name, COUNT(cust_address) AS addr_num
FROM Customers GROUP BY cust_name
ORDER BY cust_name DESC;

HAVING

HAVING 用于对汇总的 GROUP BY 结果进行过滤。HAVING 要求存在一个 GROUP BY 子句。

WHEREHAVING 可以在相同的查询中。

HAVING vs WHERE

  • WHEREHAVING 都是用于过滤。
  • HAVING 适用于汇总的组记录;而 WHERE 适用于单个记录。

使用 WHEREHAVING 过滤数据示例:

1
2
3
4
5
SELECT cust_name, COUNT(*) AS num
FROM Customers
WHERE cust_email IS NOT NULL
GROUP BY cust_name
HAVING COUNT(*) >= 1;

连接和组合

连接(JOIN)

**在 SELECT, UPDATE 和 DELETE 语句中,“连接”可以用于联合多表查询。连接使用 JOIN 关键字,并且条件语句使用 ON 而不是 WHERE**。

连接可以替换子查询,并且一般比子查询的效率更快

JOIN 有以下类型:

  • 内连接 - 内连接又称等值连接,用于获取两个表中字段匹配关系的记录,使用 INNER JOIN 关键字。在没有条件语句的情况下返回笛卡尔积
    • 笛卡尔积 - “笛卡尔积”也称为交叉连接(CROSS JOIN),它的作用就是可以把任意表进行连接,即使这两张表不相关
    • 自连接(=) - “自连接(=)”可以看成内连接的一种,只是连接的表是自身而已
    • 自然连接(NATURAL JOIN) - “自然连接”会自动连接所有同名列。自然连接使用 NATURAL JOIN 关键字。
  • 外连接
    • 左连接(LEFT JOIN) - “左外连接”会获取左表所有记录,即使右表没有对应匹配的记录。左外连接使用 LEFT JOIN 关键字。
    • 右连接(RIGHT JOIN) - “右外连接”会获取右表所有记录,即使左表没有对应匹配的记录。右外连接使用 RIGHT JOIN 关键字。
sql-join
#### 内连接(INNER JOIN)

内连接又称等值连接,用于获取两个表中字段匹配关系的记录,使用 INNER JOIN 关键字。在没有条件语句的情况下返回笛卡尔积

1
2
3
4
5
6
7
8
SELECT vend_name, prod_name, prod_price
FROM vendors INNER JOIN products
ON vendors.vend_id = products.vend_id;

-- 也可以省略 INNER 使用 JOIN,与上面一句效果一样
SELECT vend_name, prod_name, prod_price
FROM vendors JOIN products
ON vendors.vend_id = products.vend_id;
笛卡尔积

“笛卡尔积”也称为交叉连接(CROSS JOIN),它的作用就是可以把任意表进行连接,即使这两张表不相关。但通常进行连接还是需要筛选的,因此需要在连接后面加上 WHERE 子句,也就是作为过滤条件对连接数据进行筛选。

笛卡尔积是一个数学运算。假设我有两个集合 X 和 Y,那么 X 和 Y 的笛卡尔积就是 X 和 Y 的所有可能组合,也就是第一个对象来自于 X,第二个对象来自于 Y 的所有可能。

【示例】求 t1 和 t2 两张表的笛卡尔积

1
2
3
-- 以下两条 SQL,执行结果相同
SELECT * FROM t1, t2;
SELECT * FROM t1 CROSS JOIN t2;
自连接(=)

“自连接”可以看成内连接的一种,只是连接的表是自身而已

1
2
3
4
SELECT c1.cust_id, c1.cust_name, c1.cust_contact
FROM customers c1, customers c2
WHERE c1.cust_name = c2.cust_name
AND c2.cust_contact = 'Jim Jones';
自然连接(NATURAL JOIN)

“自然连接”会自动连接所有同名列。自然连接使用 NATURAL JOIN 关键字。

1
2
3
SELECT *
FROM Products
NATURAL JOIN Customers;

外连接(OUTER JOIN)

外连接返回一个表中的所有行,并且仅返回来自此表中满足连接条件的那些行,即两个表中的列是相等的。外连接分为左外连接、右外连接、全外连接(Mysql 不支持)。

左连接(LEFT JOIN)

“左外连接”会获取左表所有记录,即使右表没有对应匹配的记录。左外连接使用 LEFT JOIN 关键字。

1
2
3
SELECT customers.cust_id, orders.order_num
FROM customers LEFT JOIN orders
ON customers.cust_id = orders.cust_id;
右连接(RIGHT JOIN)

“右外连接”会获取右表所有记录,即使左表没有对应匹配的记录。右外连接使用 RIGHT JOIN 关键字。

1
2
3
SELECT customers.cust_id, orders.order_num
FROM customers RIGHT JOIN orders
ON customers.cust_id = orders.cust_id;

组合(UNION)

UNION 运算符将两个或更多查询的结果组合起来,并生成一个结果集,其中包含来自 UNION 中参与查询的提取行。

UNION 基本规则:

  • 所有查询的列数和列顺序必须相同。
  • 每个查询中涉及表的列的数据类型必须相同或兼容。
  • 通常返回的列名取自第一个查询。

默认会去除相同行,如果需要保留相同行,使用 UNION ALL

只能包含一个 ORDER BY 子句,并且必须位于语句的最后。

应用场景:

  • 在一个查询中从不同的表返回结构数据。
  • 对一个表执行多个查询,按一个查询返回数据。

组合查询示例:

1
2
3
4
5
6
7
SELECT cust_name, cust_contact, cust_email
FROM customers
WHERE cust_state IN ('IL', 'IN', 'MI')
UNION
SELECT cust_name, cust_contact, cust_email
FROM customers
WHERE cust_name = 'Fun4All';

JOIN vs UNION

  • JOIN 中连接表的列可能不同,但在 UNION 中,所有查询的列数和列顺序必须相同。
  • UNION 将查询之后的行放在一起(垂直放置),但 JOIN 将查询之后的列放在一起(水平放置),即它构成一个笛卡尔积。

函数

🔔 注意:不同数据库的函数往往各不相同,因此不可移植。本节主要以 Mysql 的函数为例。

字符串函数

函数 说明
CONCAT() 合并字符串
LEFT()RIGHT() 左边或者右边的字符
LOWER()UPPER() 转换为小写或者大写
LTRIM()RTIM() 去除左边或者右边的空格
LENGTH() 长度
SOUNDEX() 转换为语音值

其中, SOUNDEX() 可以将一个字符串转换为描述其语音表示的字母数字模式。

1
2
3
SELECT *
FROM mytable
WHERE SOUNDEX(col1) = SOUNDEX('apple')

时间函数

  • 日期格式:YYYY-MM-DD
  • 时间格式:HH:MM:SS
函 数 说 明
ADDDATE() 增加一个日期(天、周等)
ADDTIME() 增加一个时间(时、分等)
CURRENT_DATE() 返回当前日期
CURRENT_TIME() 返回当前时间
DATE() 返回日期时间的日期部分
DATEDIFF() 计算两个日期之差
DATE_ADD() 高度灵活的日期运算函数
DATE_FORMAT() 返回一个格式化的日期或时间串
DAY() 返回一个日期的天数部分
DAYOFWEEK() 对于一个日期,返回对应的星期几
HOUR() 返回一个时间的小时部分
MINUTE() 返回一个时间的分钟部分
MONTH() 返回一个日期的月份部分
NOW() 返回当前日期和时间
SECOND() 返回一个时间的秒部分
TIME() 返回一个日期时间的时间部分
YEAR() 返回一个日期的年份部分
1
2
mysql> SELECT NOW();
2018-4-14 20:25:11

数学函数

常见 Mysql 数学函数:

函数 说明
ABS() 取绝对值
MOD() 取余
ROUND() 四舍五入
...

聚合函数

函 数 说 明
AVG() 返回某列的平均值
COUNT() 返回某列的行数
MAX() 返回某列的最大值
MIN() 返回某列的最小值
SUM() 返回某列值之和

AVG() 会忽略 NULL 行。

使用 DISTINCT 可以让汇总函数值汇总不同的值。

1
2
SELECT AVG(DISTINCT col1) AS avg_col
FROM mytable

转换函数

函 数 说 明 示例
CAST() 转换数据类型 SELECT CAST("2017-08-29" AS DATE); -> 2017-08-29

事务

不能回退 SELECT 语句,回退 SELECT 语句也没意义;也不能回退 CREATEDROP 语句。

MySQL 默认采用隐式提交策略(autocommit,每执行一条语句就把这条语句当成一个事务然后进行提交。当出现 START TRANSACTION 语句时,会关闭隐式提交;当 COMMITROLLBACK 语句执行后,事务会自动关闭,重新恢复隐式提交。

通过 set autocommit=0 可以取消自动提交,直到 set autocommit=1 才会提交;autocommit 标记是针对每个连接而不是针对服务器的。

事务处理指令:

  • START TRANSACTION - 指令用于标记事务的起始点。
  • SAVEPOINT - 指令用于创建保留点。
  • ROLLBACK TO - 指令用于回滚到指定的保留点;如果没有设置保留点,则回退到 START TRANSACTION 语句处。
  • COMMIT - 提交事务。
  • RELEASE SAVEPOINT:删除某个保存点。
  • SET TRANSACTION:设置事务的隔离级别。

事务处理示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- 开始事务
START TRANSACTION;

-- 插入操作 A
INSERT INTO `user`
VALUES (1, 'root1', 'root1', 'xxxx@163.com');

-- 创建保留点 updateA
SAVEPOINT updateA;

-- 插入操作 B
INSERT INTO `user`
VALUES (2, 'root2', 'root2', 'xxxx@163.com');

-- 回滚到保留点 updateA
ROLLBACK TO updateA;

-- 提交事务,只有操作 A 生效
COMMIT;

ACID

事务隔离级别


(以下为 DCL 语句用法)

权限控制

GRANTREVOKE 可在几个层次上控制访问权限:

  • 整个服务器,使用 GRANT ALLREVOKE ALL
  • 整个数据库,使用 ON database.*;
  • 特定的表,使用 ON database.table;
  • 特定的列;
  • 特定的存储过程。

新创建的账户没有任何权限。

账户用 username@host 的形式定义,username@% 使用的是默认主机名。

MySQL 的账户信息保存在 mysql 这个数据库中。

1
2
USE mysql;
SELECT user FROM user;

创建账户

1
CREATE USER myuser IDENTIFIED BY 'mypassword';

修改账户名

1
2
UPDATE user SET user='newuser' WHERE user='myuser';
FLUSH PRIVILEGES;

删除账户

1
DROP USER myuser;

查看权限

1
SHOW GRANTS FOR myuser;

授予权限

1
GRANT SELECT, INSERT ON *.* TO myuser;

删除权限

1
REVOKE SELECT, INSERT ON *.* FROM myuser;

更改密码

1
SET PASSWORD FOR myuser = 'mypass';

存储过程

存储过程的英文是 Stored Procedure。它可以视为一组 SQL 语句的批处理。一旦存储过程被创建出来,使用它就像使用函数一样简单,我们直接通过调用存储过程名即可。

定义存储过程的语法格式:

1
2
3
4
CREATE PROCEDURE 存储过程名称 ([参数列表])
BEGIN
需要执行的语句
END

存储过程定义语句类型:

  • CREATE PROCEDURE 用于创建存储过程
  • DROP PROCEDURE 用于删除存储过程
  • ALTER PROCEDURE 用于修改存储过程

使用存储过程

创建存储过程的要点:

  • DELIMITER 用于定义语句的结束符
  • 存储过程的 3 种参数类型:
    • IN:存储过程的入参
    • OUT:存储过程的出参
    • INPUT:既是存储过程的入参,也是存储过程的出参
  • 流控制语句:
    • BEGIN…ENDBEGIN…END 中间包含了多个语句,每个语句都以(;)号为结束符。
    • DECLAREDECLARE 用来声明变量,使用的位置在于 BEGIN…END 语句中间,而且需要在其他语句使用之前进行变量的声明。
    • SET:赋值语句,用于对变量进行赋值。
    • SELECT…INTO:把从数据表中查询的结果存放到变量中,也就是为变量赋值。每次只能给一个变量赋值,不支持集合的操作。
    • IF…THEN…ENDIF:条件判断语句,可以在 IF…THEN…ENDIF 中使用 ELSEELSEIF 来进行条件判断。
    • CASECASE 语句用于多条件的分支判断。

创建存储过程示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
DROP PROCEDURE IF EXISTS `proc_adder`;
DELIMITER ;;
CREATE DEFINER=`root`@`localhost` PROCEDURE `proc_adder`(IN a int, IN b int, OUT sum int)
BEGIN
DECLARE c int;
if a is null then set a = 0;
end if;

if b is null then set b = 0;
end if;

set sum = a + b;
END
;;
DELIMITER ;

使用存储过程示例:

1
2
3
set @b=5;
call proc_adder(2,@b,@s);
select @s as sum;

存储过程的利弊

存储过程的优点:

  • 执行效率高:一次编译多次使用。
  • 安全性强:在设定存储过程的时候可以设置对用户的使用权限,这样就和视图一样具有较强的安全性。
  • 可复用:将代码封装,可以提高代码复用。
  • 性能好
    • 由于是预先编译,因此具有很高的性能。
    • 一个存储过程替代大量 T_SQL 语句 ,可以降低网络通信量,提高通信速率。

存储过程的缺点:

  • 可移植性差:存储过程不能跨数据库移植。由于不同数据库的存储过程语法几乎都不一样,十分难以维护(不通用)。
  • 调试困难:只有少数 DBMS 支持存储过程的调试。对于复杂的存储过程来说,开发和维护都不容易。
  • 版本管理困难:比如数据表索引发生变化了,可能会导致存储过程失效。我们在开发软件的时候往往需要进行版本管理,但是存储过程本身没有版本控制,版本迭代更新的时候很麻烦。
  • 不适合高并发的场景:高并发的场景需要减少数据库的压力,有时数据库会采用分库分表的方式,而且对可扩展性要求很高,在这种情况下,存储过程会变得难以维护,增加数据库的压力,显然就不适用了。

_综上,存储过程的优缺点都非常突出,是否使用一定要慎重,需要根据具体应用场景来权衡_。

触发器

触发器可以视为一种特殊的存储过程。

触发器是一种与表操作有关的数据库对象,当触发器所在表上出现指定事件时,将调用该对象,即表的操作事件触发表上的触发器的执行。

触发器特性

可以使用触发器来进行审计跟踪,把修改记录到另外一张表中。

MySQL 不允许在触发器中使用 CALL 语句 ,也就是不能调用存储过程。

BEGINEND

当触发器的触发条件满足时,将会执行 BEGINEND 之间的触发器执行动作。

🔔 注意:在 MySQL 中,分号 ; 是语句结束的标识符,遇到分号表示该段语句已经结束,MySQL 可以开始执行了。因此,解释器遇到触发器执行动作中的分号后就开始执行,然后会报错,因为没有找到和 BEGIN 匹配的 END。

这时就会用到 DELIMITER 命令(DELIMITER 是定界符,分隔符的意思)。它是一条命令,不需要语句结束标识,语法为:DELIMITER new_delemiternew_delemiter 可以设为 1 个或多个长度的符号,默认的是分号 ;,我们可以把它修改为其他符号,如 $ - DELIMITER $ 。在这之后的语句,以分号结束,解释器不会有什么反应,只有遇到了 $,才认为是语句结束。注意,使用完之后,我们还应该记得把它给修改回来。

NEWOLD

  • MySQL 中定义了 NEWOLD 关键字,用来表示触发器的所在表中,触发了触发器的那一行数据。
  • INSERT 型触发器中,NEW 用来表示将要(BEFORE)或已经(AFTER)插入的新数据;
  • UPDATE 型触发器中,OLD 用来表示将要或已经被修改的原数据,NEW 用来表示将要或已经修改为的新数据;
  • DELETE 型触发器中,OLD 用来表示将要或已经被删除的原数据;
  • 使用方法: NEW.columnName (columnName 为相应数据表某一列名)

触发器指令

提示:为了理解触发器的要点,有必要先了解一下创建触发器的指令。

CREATE TRIGGER 指令用于创建触发器。

语法:

1
2
3
4
5
6
7
8
CREATE TRIGGER trigger_name
trigger_time
trigger_event
ON table_name
FOR EACH ROW
BEGIN
trigger_statements
END;

说明:

  • trigger_name:触发器名
  • trigger_time: 触发器的触发时机。取值为 BEFOREAFTER
  • trigger_event: 触发器的监听事件。取值为 INSERTUPDATEDELETE
  • table_name: 触发器的监听目标。指定在哪张表上建立触发器。
  • FOR EACH ROW: 行级监视,Mysql 固定写法,其他 DBMS 不同。
  • trigger_statements: 触发器执行动作。是一条或多条 SQL 语句的列表,列表内的每条语句都必须用分号 ; 来结尾。

创建触发器示例:

1
2
3
4
5
6
7
8
9
DELIMITER $
CREATE TRIGGER `trigger_insert_user`
AFTER INSERT ON `user`
FOR EACH ROW
BEGIN
INSERT INTO `user_history`(user_id, operate_type, operate_time)
VALUES (NEW.id, 'add a user', now());
END $
DELIMITER ;

查看触发器示例:

1
SHOW TRIGGERS;

删除触发器示例:

1
DROP TRIGGER IF EXISTS trigger_insert_user;

游标

游标(CURSOR)是一个存储在 DBMS 服务器上的数据库查询,它不是一条 SELECT 语句,而是被该语句检索出来的结果集。在存储过程中使用游标可以对一个结果集进行移动遍历。

游标主要用于交互式应用,其中用户需要对数据集中的任意行进行浏览和修改。

使用游标的步骤:

  1. 定义游标:通过 DECLARE cursor_name CURSOR FOR <语句> 定义游标。这个过程没有实际检索出数据。
  2. 打开游标:通过 OPEN cursor_name 打开游标。
  3. 取出数据:通过 FETCH cursor_name INTO var_name ... 获取数据。
  4. 关闭游标:通过 CLOSE cursor_name 关闭游标。
  5. 释放游标:通过 DEALLOCATE PREPARE 释放游标。

游标使用示例:

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
29
30
31
DELIMITER $
CREATE PROCEDURE getTotal()
BEGIN
DECLARE total INT;
-- 创建接收游标数据的变量
DECLARE sid INT;
DECLARE sname VARCHAR(10);
-- 创建总数变量
DECLARE sage INT;
-- 创建结束标志变量
DECLARE done INT DEFAULT false;
-- 创建游标
DECLARE cur CURSOR FOR SELECT id,name,age from cursor_table where age>30;
-- 指定游标循环结束时的返回值
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = true;
SET total = 0;
OPEN cur;
FETCH cur INTO sid, sname, sage;
WHILE(NOT done)
DO
SET total = total + 1;
FETCH cur INTO sid, sname, sage;
END WHILE;

CLOSE cur;
SELECT total;
END $
DELIMITER ;

-- 调用存储过程
call getTotal();

参考资料