2023年4月

1 引言

在日常工作中,我们会遇见一些慢SQL,在分析这些慢SQL时,我们通常会看下SQL的执行计划,验证SQL执行过程中有没有走索引。通常我们会调整一些查询条件,增加必要的索引,SQL执行效率就会提升几个数量级。我们有没有思考过,为什么加了索引就会能提高SQL的查询效率,为什么有时候加了索引SQL执行反而会没有变化,本文就从MySQL索引的底层数据结构和算法来进行详细分析。

2 索引数据结构对比

索引的定义:索引(Index)是帮助MySQL高效获取数据的排好序的数据结构。

索引中常见的数据结构有以下几种:

  • Hash表
  • 二叉树
  • 红黑树
  • B-Tree
  • B+Tree

Hash表
通过索引的key进行一次hash计算,就可以快速获取磁盘文件指针,对于指定索引查找文件非常快,但是对于范围查找没法支持,有时候也会出现Hash冲突的情况。

二叉树
二叉树的特点:左边子节点的数据小于父节点数据,右边子节点的数据大于父节点数据。如下图所示,如果col2是索引,查找索引为65的行元素,只需要查找两次,就可以获取到行元素所在的磁盘指针地址。

但如果是一个按照顺序递增的值,例如为col1建立索引,不再适合使用二叉树建立索引,因为此时使用二叉树建立索引将会变成一个链式索引,此时的索引结构如下图所示,如果查找6节点需要6次遍历才能找到。

红黑树
红黑树是一种二叉平衡树,可以提高查询效率,此时若再查找6节点只需要遍历3次就能找到了。但红黑树也有缺点,当存储大数据量时,树的高度就会变的不可控, 数量越大,树的高度越高,查询的效率将会大大降低。

B-Tree
B-Tree是一种多路二叉树,所具有的特点:1 叶节点具有相同的深度,叶节点的指针为空;2 所有索引元素不重复;3 节点中的数据索引从左到右递增排列。

B+Tree
B+Tree是B-Tree的变种,所具有的特点:1 非叶子节点不存储data,只存储索引(冗余),可以放更多的索引;2 叶子节点包含所有索引字段;3 叶子节点用指针连接,提高区间访问的性能。

与红黑树相比,B-Tree和B+Tree两种数据结构都更加矮胖,存储相同数量级的索引数据时,层级更低。

B-Tree和B+Tree之间一个很大的不同,是B+Tree的节点上不储存value,只储存key,而叶子节点上储存了所有key-value集合,并且节点之间都是有序的。这样的好处是每一次磁盘IO能够读取的节点更多,也就是树的度(Max.Degree)可以设置的更大一些,因为每次磁盘IO读取的磁盘页数是一定的。例如,每次磁盘IO能够读取1页=4kb,那么省去value的情况下同样一页数据能够读取更多的key,这样就大大减少了磁盘的IO次数。

此外,B+Tree也是排好序的数据结构,数据库中><或者order by等都可以直接依赖这一特性。

MySQL中对于索引使用的主要数据结构也是B+Tree,目的也是在读取数据时能够减少磁盘IO。

3 千万级数据如何用B+树索引快速查找

MySQL 官方对非叶子节点(如最上层 h = 1的节点,B+Tree高度为3) 的大小是有限制的,最大的大小是16K,可以通过以下SQL语句查询到,当然这个值是可以调的,既然官方给出这个阈值说明再大的话会影响磁盘IO效率。

从执行结果,可以看到大小为 16384,即 16K大小。

假如:B+Tree的表都存满了。主键索引的类型为BigInt,大小为8B,指针存储了下个节点的文件地址,大小为6B。最后一层,假如 存放的数据data为1K 大小,那么

  1. 第一层最大节点数为: 16k / (8B + 6B) ≈ 1170 (个);
  2. 第二层最大节点数也应为:1170个;
  3. 第三层最大节点数为:16K / 1K = 16 (个)。

则,一张B+Tree的表最多存放 1170_1170_16 ≈ 2千万。

所以,通过分析,我们可以得出,B+Tree结构的表可以容纳千万数据量的查询。而且一般来说,MySQL会把 B+Tree 根节点放在内存中,那只需要两次磁盘IO就行。

4 存储引擎索引实现

MySQL中索引储存在哪里呢?和数据一样,索引以文件形式储存在硬盘上。
在MyISAM储存引擎中,数据和索引文件试试分开储存的,数据存在.MYD结尾的文件中,索引单独存在.MYI结尾的文件中。

在InnoDB中,数据和索引文件是合起来储存的,注意下图中没有了.MYI结尾的文件,只有一个.ibd结尾的文件。

MyISAM索引文件和数据文件是分离的(非聚集),并且主键索引和辅助索引(二级索引)的储存方式是一样的。

InnoDB中索引文件和数据文件是同一个文件(聚集),并且主键索引和二级索引储存方式有所不同,如图所示,二级索引的叶子节点不储存数据,仅储存主键ID。

这里思考几个问题:

  • 为什么建议InnoDB表必须建主键,并且推荐使用整型的自增主键?
  • 为什么非主键索引结构叶子节点存储的是主键值?

如果我们在创建表时不设置主键,InnoDB会自动帮我们从第一列开始筛选一列数据不重复的列做为主键,如果找不到这样的列,就会创建一个隐藏的列(rowid)做为主键,这会增加很多MySQL的工作,所以建议我们在创建InnoDB表时一定要设置主键。

整型的字段做为主键,一方面在数据比较时不需要进行转换,另一方面存储也比较节省空间。那为什么要强调主键自增呢?如果主键id是无序的,那么很有可能新插入的值会导致当前节点分裂,此时MySQL不得不为了将新记录插到合适位置而移动数据,甚至目标页面可能已经被回写到磁盘上而从缓存中清掉,此时又要从磁盘上读回来,这增加了很多开销,同时频繁的移动、分页操作造成了大量的碎片,得到了不够紧凑的索引结构,后续不得不通过OPTIMIZE TABLE来重建表并优化填充页面。反之,如果每次插入有序,那就会在当前页后面连续写入,写不下就会重新分配一个节点,内存都是连续的,这样效率自然也就最高了。

非主键索引的叶子节点存储主键值而非全部数据,主要也是为了一致性和节省空间。如果二级索引储存的也是数据,那么每次插入MySQL都不得不更新每棵索引树,这样就加剧了新增编辑时的性能损耗,并且这样一来空间利用率也不高,必然产生了大量冗余数据。

5 联合索引底层数据结构又是怎样的

联合索引又叫复合索引,例如下表:

CREATE TABLE `test` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(24) NOT NULL,
`age` int NOT NULL,
`position` varchar(32) NOT NULL,
`address` varchar(128) NOT NULL,
`birthday` date NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_name_age_position` (`name`,`age`,`position`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

如下索引就是一个联合索引。

`idx_name_age_position` (`name`,`age`,`position`) USING BTREE

联合索引底层数据结构长什么样?

比较相等时,先比较第一列的值,如果相等,再继续比较第二列,以此类推。

了解了联合索引的存储结构,我们就知道了索引最左前缀优化原则是怎么回事了,在使用联合索引时,对于索引列的定义顺序将会影响到最终查询时索引的使用情况。例如联合索引(name,age,position),MySQL会从最左边的列优先匹配,如果最左边的带头大哥name没有使用到,在未使用覆盖索引的情况下,就只能全表扫描。

联合底层数据结构思考:MySQL会优先以联合索引第一列匹配,此后才会匹配下一列,如果不指定第一列匹配的值,也就无法得知下一步查询哪个节点。

6 总结

索引本质上是一种排好序的数据结构,了解了MySQL索引的底层数据结构及存储原理,可以帮助我们更好地进行SQL优化。其实数据库索引调优是一项技术活,不能仅仅靠理论,因为实际情况千变万化,而且MySQL本身存在很复杂的机制,如查询优化策略和各种引擎的实现差异等都会使情况变得更加复杂。但同时这些理论是索引调优的基础,只有在明白理论的基础上,才能对调优策略进行合理推断并了解其背后的机制,然后结合实践中不断的实验和摸索,从而真正达到高效使用MySQL索引的目的。

最后,如果大家想再温习一下数据结构的知识,这个数据结构网站(
https://www.cs.usfca.edu/~galles/visualization/Algorithms.html
)不可错过,可以很好地帮助我们演示数据结构的存储过程。

作者:京东物流 于朔

这篇文章翻译自
MySQL官方文档

介绍了8.0在预写式日志上实现上的修改,我把核心

观点总结如下:

在8.0以前,为了保证flush list的顺序,redo log buffer写入过程需要加锁,无法实现并行,高并发的环境中,会同时有非常多的min-transaction(mtr)需要拷贝数据到Log Buffer,如果通过锁互斥,那么毫无疑问这里将成为明显的性能瓶颈。

为此,从MySQL 8.0开始,设计了一套无锁的写log机制,其核心思路是引入recent_written,允许不同的mtr,同时并发地写Log Buffer的不同位置。

1、写入RedoLog存在性能瓶颈

预写日志 (WAL) 是数据库最重要的组件之一,对数据文件的所有更改都记录在 WAL 中(在 InnoDB 中称为Redo日志),并且推迟将修改的页面刷新(Flushed)到磁盘的时间,同时仍然防止数据丢失。

在写入Redo日志时,数据密集型写入服务的性能受到线程同步的限制会明显下降。在具有多个 CPU 内核和快速存储设备(例如现代 SSD 磁盘)的服务器上测试性能时,这一点尤为明显。

我们需要一种新的设计来解决我们的客户和用户现在和将来面临的问题。通过新设计,我们希望确保它可以与现有 API 一起使用,最重要的是不会破坏 InnoDB 其余部分所依赖的契约,在这些限制条件下,这是一项具有挑战性的任务。

重做日志可以看作是一个生产者/消费者持久化队列。执行更新的用户线程可以看作是生产者,当 InnoDB 必须进行崩溃恢复时,恢复线程就是消费者。  InnoDB服务在预期内正常工作时不需要读取Redo log。

2、多线程写Redo日志的顺序问题

实现具有多个生产者的写日志可扩展模型只是问题的一部分。还有一些 InnoDB 特定的细节也需要起作用。最大的挑战是保持buffer pool中flush list上的脏页要满足按照LSN递增排布。

首先, 一个buffer pool实例维护一个flush list, 由mtr(mini transaction)负责原子的应用对物理页的修改, 因此, mtr是InnoDB对物理文件操作的最小事务单元。 redo log由mtr产生, 通常先写在mtr的cache里, 在mtr提交时, 将cache中的redo log刷入log buffer(公共buffer), 同时递增全局维护的日志编号(LSN, Log Sequence Number)。

随后Mtr负责将修改的脏页(或一列表脏页)加入到flush list上, 且满足flush list上的脏页是按照LSN递增排序的。

在8.0之前的实现中, 我们通过加内部锁log_sys_t::mutex 和 log_sys_t::flush_order_mutex 来保证flush list上页按照写log buffer 拿到的LSN有序。

因此, 8.0前的工作方式如下: 某个mtr将脏页输入flush list时, 会持有锁flush_order_mutex, 这时, 即便另外一个线程A需要添加脏页到其他bp(buffer pool)的flush list, 也必须陷入等待。 这种情况下, 这个线A程通过持有log_sys_t::mutex, 阻塞其他线程写log buffer。 单纯移除这两个锁结构, 会使得flush list中LSN递增的约束不工作

3、解决log buffer上的LSN可能不连续

我们还面临的第二个问题是, 由于各个事务可以交叉拷贝redolog 到 log buffer中, log buffer上的LSN可能存在空洞(如下图所示), 所以log buffer是不可以一口气flush full log buffer。

我们通过跟踪已经完成的写log buffer操作(如下图所示的)来解决第二个问题。

在设计上我们引入一个新的无锁数据结构(元素排列与原先log buffer对应关系如下图)。

数据结构如下图所示。 首先这是一个定长数组, 并且保证数组元素(slot)更新是原子的, 以环形形式复用已经释放的空间(所以是个环形数组啊哈)。 并启用单独的线程负责数组的遍历和空间回收, 线程在遇到空元素(empty slot)时暂停。 因此这个thread中还维护了这个数据结构中的最大可达LSN, 我们假设值为M。

我们引入了这个数据结构的两个变量: recent_written 和 recent_closed。 recent_written 维护写log buffer的完成状态, recent_written中维护的最大LSN, M表示, 所有小于这个M的LSN都已经将它的redo log写入log buffer。 而这个M也是(如果这下crash, 可能会触发的)崩溃恢复的截止位点, 同时也是下一个写log buffer操作的开始位点。

刷log buffer到磁盘和遍历recent_written是交由一个线程完成, 因此对log buffer的内存读写操作通过recent_written上顺序访问元素(slots)形成的屏障保证。

假设当前log buffer和recent_written状态如上图所示, 然后完成了一次buffer log 写, 使得log buffer状态如下图所示。

log buffer状态更新触发特定线程(log_writter)向后扫描recent_written, (如下图)

然后更新它维护的最大可达LSN值(可以保证无空洞的), 写入到变量buf_ready_for_write_lsn (这个变量顾名思义 XD)

我们还引入另一个无锁结构体的变量recent_closed, 用来完成原先log_sys_t::flush_order_mutex锁所作的工作, 用来维护flush list的LSN递增性质。 在介绍实现细节前, 我们还需要解释一些细节来, 才能清晰的阐释图和使用无锁结构维护(flush list/bp)整体的LSN单调。

4、使用CheckPoint保证flush list正确

那么首先, 每个bp中的flush list有专门的内部锁保护。 但是我们已经移除了了锁结构log_sys_t::flush_order_mutex, 这就使得并发写flush list的LSN递增性质保证不了。

虽然如此, flush list正确工作仍然必须满足以下两个原生约束:

  1. Checkpoint - 对于检查点推进的约束: 假设存在脏页P1, LSN = L1, 脏页P2, LSN = L2, 如果L2 > L1, 且P1脏页未落盘, 不允许刷新L2对应的脏页到磁盘。
  2. FLushing - flush list 上刷脏策略约束: 每次flush必须从oldest page(即, page对应的LSN最小)开始。 这个操作保证最早被修改的也最先从flush list更新到磁盘, 同时还向后推进checkpoint_lsn。

新引入的无锁结构recent_closed, 用来跟踪并发写flush list的状态, 同时也维护一个最大LSN, 我们标记为M, 满足, 小于当前LSN的脏页都已经加入到flush list中。

只有在M值与当前线程不那么远的时候, 才能将它的脏页刷flush list。 在线程将脏页写入flush list后, 更新recent_closed中的状态信息。

HTTP最早被用来做浏览器与服务器之间交互HTML和表单的通讯协议;后来又被被广泛的扩充到接口格式的定义上。所以在讨论GET和POST区别的时候,需要现确定下到底是浏览器使用的GET/POST还是用HTTP作为接口传输协议的场景。

浏览器的GET和POST

这里特指浏览器中非Ajax的HTTP请求,即从HTML和浏览器诞生就一直使用的HTTP协议中的GET/POST。浏览器用GET请求来获取一个html页面/图片/css/js等资源;用POST来提交一个<form>表单,并得到一个结果的网页。

浏览器将GET和POST定义为:


GET

“读取“一个资源。比如Get到一个html文件。反复读取不应该对访问的数据有副作用。比如”GET一下,用户就下单了,返回订单已受理“,这是不可接受的。没有副作用被称为“幂等“(Idempotent)。

因为GET因为是读取,就可以对GET请求的数据做缓存。这个缓存可以做到浏览器本身上(彻底避免浏览器发请求),也可以做到代理上(如nginx),或者做到server端(用Etag,至少可以减少带宽消耗)

POST

在页面里<form> 标签会定义一个表单。点击其中的submit元素会发出一个POST请求让服务器做一件事。这件事往往是有副作用的,不幂等的。

不幂等也就意味着不能随意多次执行。因此也就不能缓存。比如通过POST下一个单,服务器创建了新的订单,然后返回订单成功的界面。这个页面不能被缓存。试想一下,如果POST请求被浏览器缓存了,那么下单请求就可以不向服务器发请求,而直接返回本地缓存的“下单成功界面”,却又没有真的在服务器下单。那是一件多么滑稽的事情。

因为POST可能有副作用,所以浏览器实现为不能把POST请求保存为书签。想想,如果点一下书签就下一个单,是不是很恐怖?。

此外如果尝试重新执行POST请求,浏览器也会弹一个框提示下这个刷新可能会有副作用,询问要不要继续。


当然,服务器的开发者完全可以把GET实现为有副作用;把POST实现为没有副作用。只不过这和浏览器的预期不符。
把GET实现为有副作用是个很可怕的事情
。 我依稀记得很久之前百度贴吧有一个因为使用GET请求可以修改管理员的权限而造成的安全漏洞。反过来,把没有副作用的请求用POST实现,浏览器该弹框还是会弹框,对用户体验好处改善不大。

但是后边可以看到,将HTTP POST作为接口的形式使用时,就没有这种弹框了。于是把一个POST请求实现为幂等就有实际的意义。POST幂等能让很多业务的前后端交互更顺畅,以及避免一些因为前端bug,触控失误等带来的重复提交。将一个有副作用的操作实现为幂等必须得从业务上能定义出怎么就算是“重复”。如提交数据中增加一个dedupKey在一个交易会话中有效,或者利用提交的数据里可以天然当dedupKey的字段。这样万一用户强行重复提交,服务器端可以做一次防护。

GET和POST携带数据的格式也有区别。当浏览器发出一个GET请求时,就意味着要么是用户自己在浏览器的地址栏输入,要不就是点击了html里a标签的href中的url。所以其实并不是GET只能用url,而是浏览器直接发出的GET只能由一个url触发。所以没办法,GET上要在url之外带一些参数就只能依靠url上附带querystring。但是HTTP协议本身并没有这个限制。

浏览器的POST请求都来自表单提交。每次提交,表单的数据被浏览器用编码到HTTP请求的body里。浏览器发出的POST请求的body主要有有两种格式,一种是application/x-www-form-urlencoded用来传输简单的数据,大概就是"key1=value1&key2=value2"这样的格式。另外一种是传文件,会采用
multipart/form-data格式
。采用后者是因为application/x-www-form-urlencoded的编码方式对于文件这种二进制的数据非常低效。

浏览器在POST一个表单时,url上也可以带参数,只要<form action="url" >里的url带
querystring
就行。只不过表单里面的那些用<input> 等标签经过用户操作产生的数据都在会在body里。

因此我们一般会
泛泛的说
“GET请求没有body,只有url,请求数据放在url的querystring中;POST请求的数据在body中“。但这种情况仅限于浏览器发请求的场景。

接口中的GET和POST

这里是指通过浏览器的Ajax api,或者iOS/Android的App的http client,java的commons-httpclient/okhttp或者是curl,postman之类的工具发出来的GET和POST请求。此时GET/POST不光能用在前端和后端的交互中,还能用在后端各个子服务的调用中(即当一种RPC协议使用)。尽管RPC有很多协议,比如thrift,
grpc
,但是http本身已经有大量的现成的支持工具可以使用,并且对人类很友好,容易debug。HTTP协议在微服务中的使用是相当普遍的。

当用HTTP实现接口发送请求时,就没有浏览器中那么多限制了,只要是符合HTTP格式的就可以发。HTTP请求的格式,大概是这样的一个字符串(为了美观,我在\r\n后都换行一下):

<METHOD> <URL> HTTP/1.1\r\n<Header1>: <HeaderValue1>\r\n<Header2>: <HeaderValue2>\r\n
...
<HeaderN>: <HeaderValueN>\r\n
\r\n
<Body Data....>

其中的“<METHOD>"可以是GET也可以是POST,或者其他的HTTP Method,如PUT、DELETE、OPTION……。从协议本身看,并没有什么限制说GET一定不能没有body,POST就一定不能把参放到<URL>的querystring上。因此其实可以更加自由的去利用格式。比如Elastic Search的_search api就用了带body的GET;也可以自己开发接口让POST一半的参数放在url的querystring里,另外一半放body里;你甚至还可以让所有的参数都放Header里——可以做各种各样的定制,只要请求的客户端和服务器端能够约定好。

当然,太自由也带来了另一种麻烦,开发人员不得不每次讨论确定参数是放url的path里,querystring里,body里,header里这种问题,太低效了。于是就有了一些列接口规范/风格。其中名气最大的当属REST。REST充分运用GET、POST、PUT和DELETE,约定了这4个接口分别获取、创建、替换和删除“资源”,REST最佳实践还推荐在请求体使用json格式。这样仅仅通过看HTTP的method就可以明白接口是什么意思,并且解析格式也得到了统一。

json相对于x-www-form-urlencoded的优势在于1)可以有嵌套结构;以及 2)可以支持更丰富的数据类型。通过一些框架,json可以直接被服务器代码映射为业务实体。用起来十分方便。但是如果是写一个接口支持上传文件,那么还是multipart/form-data格式更合适。

REST中GET和POST不是随便用的。在REST中, 【GET】 + 【资源定位符】被专用于获取资源或者资源列表,比如:

GET http://foo.com/books          获取书籍列表
GET http://foo.com/books/:bookId  根据bookId获取一本具体的书

与浏览器的场景类似,REST GET也不应该有副作用,于是可以被反复无脑调用。浏览器(包括浏览器的Ajax请求)对于这种GET也可以实现缓存(如果服务器端提示了明确需要Caching);但是如果用非浏览器,有没有缓存完全看客户端的实现了。当然,也可以从整个App角度,也可以完全绕开浏览器的缓存机制,实现一套业务定制的缓存框架

关于安全性

我们常听到GET不如POST安全,因为POST用body传输数据,而GET用url传输,更加容易看到。但是从攻击的角度,无论是GET还是POST都不够安全,因为HTTP本身是
明文协议

每个HTTP请求和返回的每个byte都会在网络上明文传播,不管是url,header还是body
。这完全不是一个“是否容易在浏览器地址栏上看到“的问题。

为了避免传输中数据被窃取,
必须做从客户端到服务器的端端加密。业界的通行做法就是https
——即用SSL协议协商出的密钥加密明文的http数据。这个加密的协议和HTTP协议本身相互独立。如果是利用HTTP开发公网的站点/App,要保证安全,https是最最基本的要求。

当然,端端加密并不一定非得用https。比如国内金融领域都会用私有网络,也有GB的加密协议SM系列。但除了军队,金融等特殊机构之外,似乎并没有必要自己发明一套类似于ssl的协议。

回到HTTP本身,的确GET请求的参数更倾向于放在url上,因此有更多机会被泄漏。比如携带私密信息的url会展示在地址栏上,还可以分享给第三方,就非常不安全了。此外,从客户端到服务器端,有大量的中间节点,包括网关,代理等。他们的access log通常会输出完整的url,比如nginx的默认access log就是如此。如果url上携带敏感数据,就会被记录下来。但请注意
,就算私密数据在body里,也是可以被记录下来的
,因此如果请求要经过不信任的公网,避免泄密的
唯一手段就是https
。这里说的“避免access log泄漏“仅仅是指避免可信区域中的
http代理
的默认行为带来的安全隐患。比如你是不太希望让自己公司的运维同学从公司主网关的log里看到用户的密码吧。

另外,上面讲过,如果是用作接口,GET实际上也可以带body,POST也可以在url上携带数据。所以实际上到底怎么传输私密数据,要看具体场景具体分析。当然,绝大多数场景,用POST + body里写私密数据是合理的选择。一个典型的例子就是“登录”:

POST http://foo.com/user/login
{"username": "admin","passowrd": "12345678"}
安全是一个巨大的主题,有由很多细节组成的一个完备体系,比如返回私密数据的mask,XSS,CSRF,跨域安全,前端加密,钓鱼,salt,…… POST和GET在安全这件事上仅仅是个小角色。因此单独讨论POST和GET本身哪个更安全意义并不是太大。只要记得一般情况下,私密数据传输用POST + body就好。

关于编码

常见的说法有,比如GET的参数只能支持ASCII,而POST能支持任意binary,包括中文。但其实从上面可以看到,GET和POST实际上都能用url和body。因此所谓编码确切地说应该是http中url用什么编码,body用什么编码。

先说下url。url只能支持ASCII的说法源自于
RFC1738

Thus, only
alphanumerics
, the special characters "$-_.+!*'(),", and
reserved characters used for their reserved purposes may be used
unencoded within a URL.

实际上这里规定的仅仅是一个ASCII的子集[a-zA-Z0-9$-_.+!*'(),]。它们是可以“不经编码”在url中使用。比如尽管空格也是ASCII字符,但是不能直接用在url里。

那这个“编码”是什么呢?如果有了特殊符号和中文怎么办呢?一种叫做percent encoding的编码方法就是干这个用的:

这也就是为啥我们偶尔看到url里有一坨%和16位数字组成的序列。

使用Percent Encoding,即使是
binary data
,也是可以通过编码后放在URL上

的。

但要特别注意,这个
编码方式只管把字符转换成URL可用字符,但是却不管字符集编码
(比如中文到底是用UTF8还是GBK)这块早期一直都相当乱,也没有什么统一规范。比如有时跟网页编码一样,有的是操作系统的编码一样。最要命的是浏览器的地址栏是不受开发者控制的。这样,对于同样一个带中文的url,如果有的浏览器一定要用GBK(比如老的IE8),有的一定要用UTF8(比如chrome)。后端就可能认不出来。对此常用的办法是避免让用户输入这种带中文的url。如果有这种形式的请求,都改成用户界面上输入,然后通过Ajax发出的办法。Ajax发出的编码形式开发者是可以100%控制的。

不过目前基本上utf8已经大一统了。现在的开发者除非是被国家规定要求一定要用GB系列编码的场景,基本上不会再遇到这类问题了。


顺便说一句,尽管在浏览器地址栏可以看到中文。但这种url在发送请求过程中,浏览器会把中文用字符编码+Percent Encode翻译为真正的url,再发给服务器。浏览器地址栏里的中文只是想让用户体验好些而已。

再讨论下Body。HTTP Body相对好些,因为有个Content-Type来比较明确的定义。比如:

POST xxxxxx HTTP/1.1
...
Content-Type: application/x-www-form-urlencoded ; charset=UTF-8

这里Content-Type会同时定义请求body的格式(application/x-www-form-urlencoded)和字符编码(UTF-8)。

所以body和url都可以提交中文数据给后端,但是POST的规范好一些,相对不容易出错,容易让开发者安心。对于GET+url的情况,只要不涉及到在老旧浏览器的地址栏输入url,也不会有什么太大的问题。

回到POST,浏览器直接发出的POST请求就是表单提交,而表单提交只有application/x-www-form-urlencoded针对简单的key-value场景;和multipart/form-data,针对只有文件提交,或者同时有文件和
key-value
的混合提交表单的场景。

如果是Ajax或者其他HTTP Client发出去的POST请求,其body格式就非常自由了,常用的有json,xml,文本,csv……甚至是你自己发明的格式。只要前后端能约定好即可。

浏览器的POST需要发两个请求吗?

上文中的"HTTP 格式“清楚的显示了HTTP请求可以被大致分为“请求头”和“请求体”两个部分。使用HTTP时大家会有一个约定,即所有的“控制类”信息应该放在请求头中,具体的数据放在请求体里“。于是服务器端在解析时,总是会先完全解析全部的请求头部。这样,服务器端总是希望能够了解请求的控制信息后,就能决定这个请求怎么进一步处理,是拒绝,还是根据content-type去调用相应的解析器处理数据,或者直接用zero copy转发。

比如在用Java写服务时,请求处理代码总是能从HttpSerlvetRequest里getParameter/Header/url。这些信息都是请求头里的,框架直接就解析了。而对于请求体,只提供了一个inputstream,如果开发人员觉得应该进一步处理,就自己去读取和解析请求体。这就能体现出服务器端对请求头和请求体的不同处理方式。

举个实际的例子,比如写一个上传文件的服务,请求url中包含了文件名称,请求体中是个尺寸为几百兆的压缩二进制流。服务器端接收到请求后,就可以先拿到请求头部,查看用户是不是有权限上传,文件名是不是符合规范等。如果不符合,就不再处理请求体的数据了,直接丢弃。而不用等到整个请求都处理完了再拒绝。

为了进一步优化,客户端可以利用HTTP的Continued协议来这样做:客户端总是先发送所有请求头给服务器,让服务器校验。如果通过了,服务器回复“100 - Continue”,客户端再把剩下的数据发给服务器。如果请求被拒了,服务器就回复个400之类的错误,这个交互就终止了。这样,就可以避免浪费带宽传请求体。但是代价就是会多一次Round Trip。如果刚好请求体的数据也不多,那么一次性全部发给服务器可能反而更好。

基于此,客户端就能做一些优化,比如内部设定一次POST的数据超过1KB就先只发“请求头”,否则就一次性全发。客户端甚至还可以做一些Adaptive的策略,统计发送成功率,如果成功率很高,就总是全部发等等。不同浏览器,不同的客户端(curl,postman)可以有各自的不同的方案。不管怎样做,优化目的总是在提高数据吞吐和降低带宽浪费上做一个折衷。

因此到底是发一次还是发N次,客户端可以很灵活的决定。因为不管怎么发都是符合HTTP协议的,因此我们应该视为这种优化是一种实现细节,而不用扯到GET和POST本身的区别上。更不要当个什么世纪大发现。

到底什么算请求体

看完了上面的内容后,读者也许会对“什么是请求体”感到困惑不已,比如x-www-form-endocded编码的body算不算“请求体”呢?

从HTTP协议的角度,“请求头”就是Method + URL(含querystring)+ Headers;再后边的都是请求体。

但是从业务角度,如果你把一次请求立即为一个调用的话。比如上面的

POST http://foo.com/books
{"title": "wow","author": "admin",
...
}

用Java写大概等价于

createBook("wow", "admin");

那么这一行函数名和两个参数都可以看作是一个
请求,不区分头和体
。即便用HTTP协议实现,title和author编码到了HTTP请求体中。Java的HttpServletRequest支持用getParameter方法获取x-www-url-form-encoded中的数据,表达的意思就是“请求“的”参数“。

对于HTTP,
需要区分【头】和【体】
,Http Request和Http Response都这么区分。Http这么干主要用作

  • 对于HTTP代理
    • 支持转发规则,比如nginx先要解析请求头,拿到URL和Header才能决定怎么做(转发proxy_pass,重定向redirect,rewrite后重新判断……)
    • 需要用请求头的信息记录log。尽管请求体里的数据也可以记录,但一般只记录请求头的部分数据。
    • 如果代理规则不涉及到请求体,那么请求体的字节流可以直接转发,无需解析。而解析需要额外的内存和CPU。(具体的转发形式要看是否是chunked编码,代理buffering是否开启等)
    • ……
  • 对于HTTP服务器
    • 可以通过请求头进行ACL控制,比如看看Athorization头里的数据是否能让认证通过
    • 可以做一些拦截,比如看到Content-Length里的数太大,或者Content-Type自己不支持,或者Accept要求的格式自己无法处理,就直接返回失败了。
    • 如果body的数据很大,利用Stream API,可以方便支持一块一块的处理数据,而不是一次性全部读取出来再操作,以至于占用大量内存。
    • ……

但从高一级的业务角度,我们在意的其实是【请求】和【返回】。当我们在说“请求头”这三个字时,也许实际的意思是【请求】。而用HTTP实现【请求】时,可能仅仅用到【HTTP的请求头】(比如大部分GET请求),也可能是【
HTTP请求头
】+【HTTP请求体】(比如用POST实现一次下单)。

总之,这里有两层,不要混哦。

关于URL的长度

因为上面提到了不论是GET和POST都可以使用URL传递数据,所以我们常说的“GET数据有长度限制“其实是指”URL的长度限制“。

HTTP协议本身对URL长度并没有做任何规定。实际的限制是由客户端/浏览器以及服务器端决定的。

先说浏览器。不同浏览器不太一样。比如我们常说的2048个字符的限制,其实是IE8的限制。并且原始文档的说的其实是“URL的最大长度是2083个字符,path的部分最长是2048个字符“。见

。IE8之后的IE URL限制我没有查到明确的文档,但有些资料称IE 11的地址栏只能输入法2047个字符,但是允许用户点击html里的超长URL。我没实验,哪位有兴趣可以试试。

Safari,Firefox等浏览器也有自己的限制,但都比IE大的多,这里就不挨个列出了。

然而新的IE已经开始使用Chrome的内核了,也就意味着“浏览器端URL的长度限制为2048字符”这种说法会慢慢成为历史。

其他的客户端,比如Java的,js的http client大多数也并没有限制URL最大有多长。

除了浏览器,服务器这边也有限制,比如apache的LimieRequestLine指令。

apache实际上限制的是HTTP请求第一行“Request Line“的长度,即<METHOD><URL> <VERSION>那一行。

再比如nginx用
large_client_header_buffers
指令来分配请求头中的很长数据的buffer。这个buffer可以用来处理url,header value等。

Tomcat的限制是web.xml里maxHttpHeaderSize来设置的,控制的是整个“请求头”的总长度。

为啥要限制呢?如果写过解析一段字符串的代码就能明白,解析的时候要分配内存。对于一个字节流的解析,必须分配buffer来保存所有要存储的数据。而URL这种东西必须当作一个整体看待,无法一块一块处理,于是就处理一个请求时必须分配一整块足够大的内存。如果URL太长,而并发又很高,就容易挤爆服务器的内存;同时,超长URL的好处并不多,我也只有处理老系统的URL时因为不敢碰原来的逻辑,又得追加更多数据,才会使用超长URL。

对于开发者来说,使用超长的URL完全是给自己埋坑,需要同时要考虑前后端,以及中间代理每一个环节的配置。此外,超长URL会影响搜索引擎的爬虫,有些爬虫甚至无法处理超过2000个字节的URL。这也就意味着这些URL无法被搜到,坑爹啊。

其实并没有太大必要弄清楚精确的URL最大长度限制。我个人的经验是,只要某个要开发的资源/api的URL长度
有可能达到2000个bytes以上,就必须使用body来传输数据,除非有特殊情况
。至于到底是GET + body还是POST + body可以看情况决定。

留意,1个汉字字符经过UTF8编码 + percent encoding后会变成9个字节,别算错哦。

总结

上面讲了一大堆,是希望读者不要死记硬背GET和POST的区别,而是能从更广的层面去看待和思考这个问题。

最后,
协议都是人定的
。只要客户端和服务器能彼此认同,就能工作。在常规的情况下,用符合规范的方式去实现系统可以减少很多工作量——大家都约定好了,就不要折腾了。但是,总会有一些情况用常规规范不合适,不满足需求。这时思路也不能被规范限制死,更不要死抠RFC。这些规范也许不能处理你遇到的特殊问题。比如:

  • Elastic Search的_search接口使用GET,却用body来表达查询,因为查询很复杂,用querystring很麻烦,必须用json格式才舒服,在请求体用
    json编码
    更加容易,不用折腾percent encoding。
  • 用POST写一个接口下单时可能也要考虑幂等,因为前端可能实现“下单按键”有bug,造成用户一次点击发出N个请求。你不能说因为POST by design应该是不幂等就不管了。

协议是死的,人是活的。遇到实际的问题时灵活的运用手上的工具满足需求就好。

参考

作者:京东零售  张梦雨

云技术和我们的生活息息相关,日常生活中访问的网页,刷的短视频,用的云盘等都是云计算提供的服务。那在云计算时代,前端可以做什么呢?

一、云技术与前端

在前端发展初期,前端只需完成静态页面和交互的开发即可,然后将源文件给后端部署;之后前后端分离,有了工程化的概念,前端需要自己去完成构建、打包、集成、部署,部署方式有通过CI/CD工具进行命令工具部署、Docker镜像部署、平台化部署等。随着nodejs、跨端技术的快速发展,进入大前端时代,前端工程师也可以做全栈开发,需要了解学习的端和技术越来越多。

前后端分离后,各种前端框架层出不穷,百花齐放,随着三大框架的盛行以及前端工程化的成熟,各端分工更加明确,此时,云计算在前端领域起到了很重要的作用,主要是可以在云里拿一些资源来支撑业务开发,比如各种工程化工具、开源库等,实现代码的共享,提高了开发效率。

二、前端开源库

相信作为前端大家都使用过Vue、React等耳熟能详的JavaScript框架,使用vue-cli、create-react-app等脚手架工具能快速的生成一个可独立运行的Vue、React项目。因为它是可以独立运行的,所以需要依赖NodeJS,NodeJS是一个基于Chrome V8引擎的JavaScript运行环境,它可以使JavaScript运行在服务端。说到NodeJS,不得不提NPM。

NPM,全称Node Package Manager,是一个NodeJS包管理和分发工具,即包管理器,管理第三方依赖。它以多种方式自动处理项目依赖关系,提供了命令行工具,可以安装、卸载、更新三方包,配置项目设置,运行脚本等等。目前主流的包管理工具有npm、yarn、pnpm等。npm是 2010 年发布的nodejs依赖管理工具,yarn是 Facebook 于 2016 年 发布的替代npm的包管理工具,pnpm是 2017 年发布的一款替代npm包管理工具,具有速度快、节省磁盘空间的特点。

NPM是JavaScript运行时环境Node.js的默认包管理器。采用npm命令下载三方包,下载的包会在node_modules文件夹中,可进行按需引入,实现了代码共享。目前Github提供了很多开源NPM包,虽然用起来很方便,但是怎么保证包的安全性是一个一直在探讨的问题。

前段时间,npm开源库作者以反战为名,在node-ipc库中添加恶意代码,代码先是针对俄罗斯和白俄罗斯用户IP,尝试覆盖当前目录、父目录和根目录的所有文件,后改成了往桌面上写个
WITH-LOVE-FROM-AMERICA.txt 的宣言文件。这个事件受到了开源圈强烈的谴责,造成了很不好的影响。该供应链投毒事件同时也暴漏了JS/node/npm生态的脆弱。该事件也对我们起了警示作用,怎么避免开源库的安全隐患显得尤为重要。

三、开源库使用安全指南

1. 避免下载未知或不可信的包

在进行开源库的选型时,需要检查开源许可证,关注stars、 forks、 commit frequency、contributors 等相关指标,查看包的安全策略。

2. npm ci 代替 npm install

npm ci和npm install的区别主要在于执行npm ci命令时,项目必须要有package-lock.json文件,如果package-lock.json中的依赖与package.json中的依赖不匹配时,则将退出并显示错误,该命令不会更改

package-lock.json和package.json。因此,当我们进行CI(持续集成)/CD(持续部署)或生产发布时,尽量使用npm ci,它会严格按照package-lock.json文件中指定的包版本进行安装,防止由于版本问题产生问题。

3. 安装和使用npm包时,忽略运行脚本,最小化攻击面

当使用的包有新版本更新时,不要盲目升级,在升级之前查看下版本的更改日志、发行说明和代码,关注其他人的使用体验。在安装软件包时,确保添加–ignore-scripts 后缀以禁止第三方软件包执行任何脚本。考虑将 ignore-scripts 添加到.npmrc 项目文件或全局 npm 配置中。

4. 及时升级过时的依赖项

盲目升级包版本不可取,但是当包版本过时不去升级也会带来一系列问题。npm outdated命令可以查看哪些包已经过期了。其中黄色的依赖包对应package.json中指定的版本,红色的依赖包表示有可用的更新。

5. 使用安全工具来扫描npm包

大家拿到项目之后执行npm install,之后将项目运行起来,几乎没人关注安装了什么。安全问题不容忽视,接下来介绍的几个扫描工具能快速帮你识别项目中的依赖有哪些漏洞。比较常用的扫描工具有npm audit、yarn audit、snyk等。

(1)npm audit

是npm的官方检查工具,npm6 新增的一个命令,漏洞数据来自于GitHub Advisory Database,npm audit 对第三方包的扫描依赖于 package.json 和 package-lock.json 文件,如果没有这两个文件会报错。

注意:京东源不支持,需要切换其他源

nrm ls
nrm use npm 切换源


npm audit 生成安全报告
High/Low/Moderate/Critical:安全漏洞等级
Package:存在漏洞的包名称
Dependency of: 当前工程直接依赖的包名称
Path: 漏洞完整依赖路径
More info: 漏洞详情


npm audit fix 安全漏洞修复
自动修复风险库,原理是升级依赖库,将库升级到已修复了该风险的版本号


npm audit fix --force 强制修复漏洞
对于非兼容性的依赖包升级需要执行该命令,谨慎操作,可能会导致项目不能运行。


npm audit --json 打印出一个详细的json格式的安全报告,里面有漏洞的详情和修复策略
actions:包含所有漏洞的修复策略
"update"更新版本号 
"install"修复直接依赖 
"install major"强制升级依赖,跨越主版本 
"review"不可自动修复,需要人工review


advisories: 包含所有漏洞的详情
"cves":CVE漏洞编号
"severity":漏洞等级
"vulnerable_versions":受影响的版本
"patched_versions":已修复的版本


(2)yarn audit

yarn audit无法自动修复,需要执行yarn upgrade手动更新版本号


(3)synk

Snyk 是一家美国的网络安全公司,它维护自己的开源漏洞数据库,包含多语言,多个包管理工具的漏洞。

Snyk cli是一个开发者优先的,自动发现依赖包的安全漏洞的工具,帮助开发者们在开发阶段就能查找、修复和监测代码的脆弱性。

安装: npm install -g snyk
授权: snyk auth


扫描:snyk test 


6. 及时披露发现的漏洞

如果找到任何漏洞或安全问题,及时报告给npm社区并更新相关的npm包。

四、自建代码库使用安全指南

1. 不要把敏感信息提交到NPM库中

npm包发布时会根据.gitignore 、.npmignore、package.json文件中的"file"属性决定要忽略掉那些文件和要包括哪些文件。.gitignore和.npmignore文件两者之间并不是叠加关系,而是替代关系。.npmignore文件的优先级更高,会替掉.gitignore文件的作用,建议使用.gitignore。提交时切记将敏感信息登记在.gitignore中。最优的方案是使用package.json文件中的"file"属性来控制要包含的文件,虽然比较麻烦,但是是最安全的做法。

2. 重要数据进行加密传输

五、常见的漏洞数据库

1. CVE

CVE是通用漏洞披露(Common Vulnerabilities and Exposures) 的简称,是一个记录常见漏洞的资料库。CVE对每一个漏洞都会有一个专属的编号,格式为CVE-YYYY-NNNNN。YYYY为漏洞披露年份,NNNNN为流水编号。

2. CNNVD

CNNVD是中国国家信息安全漏洞库,于2009年10月18日正式成立。

3. NVD

NVD是美国国家漏洞数据库,创建于2000年。

以上为云计算时代,前端如何保证开源代码安全性的一些个人见解和看法,欢迎大家一起交流学习~