接口设计的那些事

接口的一般性问题

很多程序员开发接口的时候,往往仅关注功能实现,但决定接口质量的恰恰是非功能性方面——遗憾的是,这一点在很多公司,从项目到产品到研发,甚至到测试,都未得到应有的重视。

接口的非功能性要素主要体现在如下几个方面:

  1. 幂等性;
  2. 鲁棒性;
  3. 安全性;

幂等性

如果某一天你在超市消费了 1000 元,而你的银行卡被扣了 2000 元,你是什么感受?

(当然你我几乎不会遇到这种问题,因为金融级别软件出现这种低级错误,估计是不想在市面上混了。)

重复扣款涉及到接口的幂等性问题。

幂等性是指写型接口必须保证重复调用时的数据正确性,一般出现在添加数据的场景,以及一些非幂等修改的场景(如扣减余额)。删除场景一般具备幂等性。

我们无法预期接口调用方如何调接口,可能由于调用超时,或者调用方实现问题(比如前端用户可短时间内高频点击),接口设计必须将重复调用作为常态考虑——因接口被重复调用而导致数据问题,责任应归于接口实现者而不是调用者。

处理幂等性的手段一般分业务逻辑层面数据库层面


业务逻辑层面:select + insert:

这种方式应用得很多,实现方式是在添加或修改数据之前先根据请求参数(如用户编号、订单编号)查一下相关数据,以决定该请求是否已经处理过了,防止重复处理(如重复加积分、重复扣款)。

这种处理方式的优点是它本身属于业务逻辑的一部分,产品和开发人员画流程图时往往会自然而然地包括这些逻辑,因而也是最容易想到的实现方式——容易想到就意味着现实中大部分的系统已经实现了这种基本的幂等性处理。

但这种 select + insert 解决不了并发问题:在极短的时间内发生的重复请求,比如用户疯狂地点击按钮(假如按钮没做任何限制)、羊毛党薅羊毛等。

在高并发时,同一个用户的两个请求几乎同时到达,此时两个请求几乎同时 select,都发现数据库没有相关记录,于是都能执行后续业务逻辑。

所以对于重要场景(如发券、积分等),请求必须在用户级别具有排他性:同一时间同一个用户只能有一个请求在处理,多个同样的请求必须串行处理。

我们可以借助 Redis 来实现分布式请求锁。根据相关请求参数生成 redis key,比如在增加积分场景,可以根据“用户 id + 场景 id” 生成 key 作为锁,请求到来时先检查锁是否存在,如果存在则直接拒绝处理,不存在的话才进入下一步。这样就保证了请求的排它性。流程图如下:

image-20220321164004286

然而,当你的数据库使用读写分离时,你会发现请求锁方案有时还是会出现漏网之鱼。业务系统处理完成后会解除请求锁,此时同一个用户的重复请求就可以进来,但此时新数据可能还没有同步到从库,因而 select 仍然查不到,于是业务逻辑又被执行了一遍(如加了两次积分)。你可能觉得这种延迟在毫秒级,问题不大,但如果对方是脚本薅羊毛,这可能就是不容忽视的问题。

这种情况必须结合数据库层面的约束来解决。

Redis 分布式锁:

Redis 的高性能、高并发和单线程处理(命令的原子性)很适合做分布式锁。有些细节值得注意。

我们一般使用 Redis 的 set 带 nx 选项实现分布式锁:

> set lock_key private_val ex 20 nx

(其中 lock_key 和 private_val 是程序生成的。)

上面设置锁 lock_key,过期时间是 20 秒。其中关键在 nx 选项,它表示当 lock_key 不存在时才设置。这条指令是 setnx 的增强版,在 setnx 基础上增加了对过期时间的支持。

那么我们如何释放锁呢?直接执行 del lock_key?不行的,程序只能释放由自己加的锁,如果直接 del,那么有可能会删除掉别的进程加的锁(比如当前进程执行超时,原来的锁过期了,而此时另一个进程刚好也加了个 lock_key 的锁,此时会把另一个进程的锁删了)。

所以删除前必须判断 private_val 是不是当前进程生成的,所以必须先判断再比较:

> get lock_key

> del lock_key

这样实现有没有问题呢?还是有那么一点小问题的:这里执行了两条 Redis 命令,不具备原子性,可能出现第一条执行成功了第二条失败的情况(虽然概率很低),另外需要两次网络开销。有没有优化空间呢,可以使用 Redis 的 eval 命令执行 Lua 脚本来保证原子性(相关语言 SDK 都有支持):

> eval 'if (redis.call('get', KEYS[1]) == ARGV[1]) then redis.call('del', KEYS[1]);end return 1;' lock_key private_val

(Lua 语言很简单,自行百度, 1 小时学会。)


数据库层面:

我们可以通过数据库提供的唯一键约束来实现幂等性。

我们看看储值卡扣费场景。电商的储值卡支付场景中,储值卡扣费环节至少要发生两个操作:

  1. 产生一笔流水,至少包含订单号和支付金额;
  2. 储值卡账户扣除相应金额;

如果储值卡支付接口不做任何幂等性处理,那就有可能同一笔订单会产生两笔支付流水且卡账户被重复扣款,造成客诉。

这里我们除了可以采用前面的“请求锁+select+insert”方案,还可以在数据库层面增加唯一键约束。假如一笔订单仅支持支付一次,那么就可以用订单号做唯一键约束,当同一笔订单进行多次支付(插入流水)时就会因唯一键冲突而插入失败(账户余额变更操作和增加流水在一个数据库事务中,自然也不会成功)。

有些场景的唯一性约束体现在组合键上,比如签到,用户一天只能签到一次,那么就可以用“用户id+日期”这样的组合唯一键。

当然,有些场景可能压根就不存在这样的唯一约束字段,比如增减积分、发券,此时必须创造出单独的约束字段来实现唯一性约束,比如给表增加一个 uniqid 并建立唯一键索引。现在的问题是 uniqid 从哪里来?

这种情况下基本上接口提供方无法根据接口请求参数生成唯一标识,必须由接口调用方提供这个 uniqid。接口提供方(如券系统)在写入数据的时候(如给用户发券)会将该 uniqid 存入,如果之前已经写入过,则会发生唯一键冲突,数据写入失败。

那么现在的问题是,如何保证接口调用方生成的标识是唯一的呢?如果调用方生成的标识和其他请求的标识冲突了,就会导致本次接口调用永远会失败。

一般有两种方案:1. 调用方根据某种规则自行生成标识;2. 由接口提供方提供单独的生成标识的接口。

调用方自行生成,可以采用 uuid 算法生成(一般编程语言都有相应的库)。uuid 能很好地保证唯一性,但缺点一方面是比较长(至少占用 16 字节),另外它是无序的,对 MySQL 这样的 B+ 树索引不是很友好,可以采用 twitter 开源的雪花算法(snowflake,网上也有现成的实现库)方案来生成 64 bit 整型(long)标识。

如果系统并发量不是特别高,而且也不想让客户端去生成唯一标识,可以由业务系统或者独立的发号器系统提供唯一标识接口来获取唯一标识。

发号器系统(有可能就是相关业务系统自身)可以采用现成的 uuid 或 snowflake 方案,也可以自行实现。此处提供一种实现思路。

假如我们要生成的唯一标识格式是 xxxxxxxxyyyyyyyyyyyyzzzz,其中 x 是当前日期,y 是 12 位十进制(千亿),每天从 1 开始自增,z 是四位随机数,主要防止万一 y 位出现异常重复的情况下降低标识符重复概率。该唯一标识在不考虑随机位 z 的情况下,每天能生成约 9 千亿个标识。

发号器服务器一般不止一台,所以需要保证多台服务器生成的 y 部分不会重复,我们采用中间服务 Redis 来分配 y 部分。

那么,是不是每次生成标识符都要请求 Redis 呢?如此 Redis 的压力可就大了。所以 y 部分我们要采用批量分配策略,即发号器系统一次向 Redis 申请一个号段,比如一次申请包含 1 万个值的 y 号段,将号段的起止值记录在本地内存中,生成标识符的时候先从本地号段中取 y 值,只有本地号段用完了才向 Redis 申请新号段。

发号器系统的本地号段是记录在内存中的(进程的全局变量),服务退出重启后会重新向 Redis 申请号段。所以号段范围建议不能太大,否则如果服务重启次数较多可能会耗尽 y 号段。

流程如下:

image-20220322113956369


总结一下如何用数据唯一键实现接口幂等性:

  1. 适用于插入数据的场景,典型的如“流水+总账”模式的业务(如储值、积分、点赞等)。
  2. 优先使用业务字段本身实现唯一性约束,比如储值卡消费流水中的订单号。或者是若干字段(2、3 个)的组合键唯一约束,如点赞场景。
  3. 当没有业务字段做唯一约束时,可创建单独标识字段做唯一约束,此时由调用方提供唯一标识符。
  4. 需保证调用方标识符的唯一性,可采用业界标准的 uuid、snowflake 算法,也可以自己实现。标识符可以由调用端自行生成,也可以由发号器统一生成,根据自己的实际情况和并发量做决策。
  5. 发号器的实现必须考虑其可扩展性,需保证发号器集群生成的标识具有唯一性。
  6. 数据库唯一键约束可能会和请求锁、“select+insert”方案一起使用。

关于接口幂等性还有个需要关注的问题:当服务提供方发现本次调用已被处理(本次可能是调用方超时重试,也可能是其它异常调用),应该返回什么?

有些开发者想当然地从业务判重角度将重复操作作为异常场景看待,不假思索地返回个错误码,这会给调用端带来困扰,很可能带来数据完整性问题。

此时最简单的做法是直接返回 OK——如果开发团队中只有一种状态码表示“成功”的话(如 code=200)。

有些开发团队借鉴 HTTP 状态码的定义,将 20X 状态码段定义为成功码,此时可以就“操作成功”和“该操作已处理过”定义不同的状态码(如 200 表示成功,201 表示该操作已处理过),这样既不干扰调用端的业务处理,也能让业务端确切知道本次调用的实际处理情况。


前后端的幂等性:

考虑下面的场景:

张三在管理后台创建券,点击“创建”按钮后半天没响应(网络较慢),于是张三又连续点了若干次,结果去列表一看,创建了三四张券。

当然你我第一反应很可能是在前端做交互优化:点击按钮后将按钮置灰,并提示“正在创建中...”,直到后端返回数据后按钮才可以再次点击。

上面的前端交互优化确实可以解决绝大部分重复创建的问题。

不过,试想一下这样的场景:

用户点击创建按钮后,后端服务处理较慢(如服务器负载高了),前端按钮置灰,用户不可点击。

过了一会(如 5 秒钟),前端接口等待时间超过阈值,前端 js 直接报超时错误,告知用户“服务处理超时,请稍后重试”。

于是用户再次点击“创建”按钮。

然后,用户去券列表页面,很可能会发现自己创建了两张券。

问题出在当前端发现后端接口超时后,会认为事务处理失败,于是提示用户重试,但后端事务实际上仍在执行(甚至有可能后端事务其实早都执行完了,但在返回数据时出现了网络问题而超时),此时用户再次点击“创建”按钮实际上会执行两次事务(创建两张券)。

所以在前后端调用的场景中(主要是创建型事务的场景),同样需要通过唯一标识(如 uuid)来保证接口调用的幂等性。

首先我们想到用类似前面“请求锁”方案(但这次不是加锁):

  1. 在渲染创建页面的时候,后端生成一个唯一标识符 X,将其保存到 Redis 中(设置一个合理的有效期),并将该标识符返回给前端;
  2. 前端请求后端“创建优惠券”接口时,带上该标识符;
  3. 后端先比较该标识符是否和 Redis 中的一致,标识符没问题才进行后续的事务处理;
  4. 后端事务处理成功后,删除掉 Redis 中的标识符;
  5. 前端在使用该标识符请求后端,后端由于检测不到该标识符,会直接返回错误;

流程如下:

image-20220322175508756

上面的流程有没有问题呢?

它确实能阻止一部分重复提交,但不是全部。

试想前端请求后端接口,后端接口超时了(但实际上后端事务仍然在执行中),此时前端会让用户重试,用户再次提交,这第二次接口请求仍然会带上刚才的 flag,那这次 flag 校验是否会通过呢?可能会,也可能不会,取决于第二次请求到达时,前一次的事务有没有处理完(从而删除掉 flag)。假如前一次的事务(这里的事务不是说数据库事务,而是指该接口要做的事情)还没有处理完,那么这个 flag 就仍然是合法的,那么第二次请求仍然会被处理。如下图:

image-20220322220950452

图中橙色和蓝色部分代表两次请求的处理流程(省略了 Redis 部分)

我们也不能在接口处理完之前删除掉 Redis 中的 flag,因为如果事务处理失败,是需要前端重新提交的。

要想前后端交互真正的实现幂等性,必须借助数据库的唯一键约束。和前面的一样,我们给数据表增加一个专门字段(假如就叫 flag)做唯一性约束,我们以券为例,数据表大致长这样:

	id	|	name	|	...	|	flag
   ---------------------------------------------------------------------------------------------------------
       122          5元优惠券            ...	    122174813112

这里的 flag 就是上面我们生成并存储到 Redis 的那个唯一标识,我们在数据库插入券数据的时候一并写进去。由于 flag 字段是唯一键,如果先前已经写入过了,再写入就会报唯一键冲突错误,写入失败,从而保证了接口的幂等性。如此,上图中用户再次点击提交,虽然flag 校验仍然会成功,但两次处理只有一次会真正成功,另一次在写数据库时会失败(不能保证一定是第一次请求写入成功,网络调用不具备时序性)。

加上数据库约束后两次请求的处理过程如下:

image-20220322223331381

图中橙色和蓝色部分代表两次请求的处理流程(省略了 Redis 部分)

有人可能觉得有了数据库层的唯一性校验,就可以去掉 Redis 那一层的校验。这是不行的,如果去掉 Redis 这层校验,我们便无法保证前端传的这个 flag 是我们自己生成的,也就是说前端随便传个 flag 就能写库了。

总结一下前后端接口调用的幂等性实现:

  1. 通过前端 js 限制用户高频次点击导致的重复提交,这是成本最低、最快见效的实现方式;
  2. 通过 Redis 实现标识符校验,结合前端 js 控制,能够满足大部分的幂等性要求;
  3. 再加上数据库层面的唯一键约束,能够真正实现前后端交互的幂等性;

讲完幂等性,我们看看第二个接口设计原则:鲁棒性。


鲁棒性

“鲁棒”这个词真的误人子弟,反正我第一次听到这个词时脑海中冒出的是一个粗鲁的大汉挥舞着棒子不知在干啥。

“鲁棒”是音译,英文叫 Robustness,翻译过来是“坚固性,健壮性”的意思,所以接口的鲁棒性是指接口的健壮性如何。

接口的鲁棒性取决于它对异常场景的承载能力

什么样的接口不具备鲁棒性呢?如果一个接口严重依赖于外部输入的合法性以及第三方服务的正确性,一旦外部输入非预期内容(如含有 SQL 注入的字符串),或者所依赖的第三方服务(接口)崩溃了(如超时),该接口就会出现各种未知问题(最典型的是数据一致性问题,如卡账扣款了但订单还是未支付状态),那么我们说该接口是脆弱的,不具备鲁棒性。

几乎所有的程序员都能写出可用的接口(实现正常流程),但至少有一半(其实不止)的程序员写不出健壮的接口。

这里的异常主要包括:

  1. 输入异常;
  2. 流程异常;
  3. 性能异常;

输入异常:

“不要信任外部输入”是常识,但不是所有人都正确处理这块。这里主要包括以下几块:

  1. 参数类型限制;
  2. 缺省参数处理;
  3. 恶意输入的拦截;

接口参数应遵循”最小化输入“原则,即调用端只需要关心他关心的参数,接口自身应能正确处理参数缺省值。我见过有些接口有二三十个参数,每个参数都是必填的——调用端对不需要的参数必须传缺省值(0 或空字符串),对接的人一边对接一边崩溃,还经常因某个参数传入错误导致接口报错。

异常输入这块重点在字符串类型上。

字符串的第一个威胁是 XSS 攻击。企盼每个开发人员对每个入参都做脱敏处理是不现实的,所以这一步必须在开发框架层面提供支持,控制器中拿到的参数应该是已经做过处理了的。虽然这是件很基础(基础到不值得拿出来一说)的事情,但我敢保证,市面上有一半的系统都没有做严格的参数处理——因为保证这点的唯一手段是将渗透测试作为测试的一个环节纳入到工作流程中,但大部分中小公司的产品并没有做渗透测试。退而求次,保证接口入参健壮性的次要手段(但对于大部分中小公司是最实用的)是将参数处理纳入到框架层面(有些框架天然支持这点,有些则需要定制开发)。

XSS:跨站脚本攻击(Cross Site Scripting,为了不和层叠样式表的缩写冲突而写成 XSS),是指恶意用户通过在网站中注入 javascript 脚本实现攻击(如获取 Cookie 信息)。

比如我们网站有个输入框(普通文本框或者富文本),用户在里面输入”<script>alert(document.cookie)</script>“,如果后端接口没有对该输入做任何处理就存入数据库,那么当这段文本在前端页面渲染时该脚本就会被执行获取到 Cookie 信息。

那是不是把代码里面 <script> 都去掉就行了呢?没那么简单的,比如用户输入 <img onerror="alert(document.cookie)" src="http://aaa"> 照样能执行。所以最好使用对应语言现成的开源库来过滤 XSS 脚本。

XSS 的威胁在于其生成的 js 脚本是在受信任环境执行的(处于受信任域名下,而且是在合法的登录会话中),它可以获取 Cookie(如果没有做 HttpOnly 防护)、localStorage,以及调后端接口,其威胁甚至大于 CSRF(后面会提到)。

字符串的第二个威胁是 SQL 注入。这同样是一个老掉牙的问题,老到几乎所有框架都提供了直接支持,只要你不在代码里面写原生 SQL 几乎就不会出现 SQL 注入问题——问题恰恰出在很多开发人员就是喜欢写原生 SQL,各种参数拼接,一渗透一堆问题,甚至表都让人给删了。开发人员写原生 SQL 的原因有很多,可能是开发人员对框架的数据库操作模块不熟悉,又懒得去看文档;也可能是开发人员写的 SQL 比较复杂,用框架提供的方法实现起来比较别扭;或者仅仅是个人偏好。

想要杜绝代码中的原生 SQL,最直接的方法是代码审查。代码审查的一个环节专门审查 Model 层(或仓储层)的 SQL 规范性——什么,你说你的 SQL 写在控制器里面?

一种更加自动化的方式是开发个审查工具,自动检查 Model 层出现的字符串拼接,或者对某特定方法的调用。

字符串的第三个威胁是格式。强制对每个输入字符串都做长度限制是个好习惯,它能防止一些不必要的麻烦——你的接口产生的数据会被别的地方用到,不能保证别的地方都能正确处理这些超长数据。对特定字段做格式限制是必要的,比如邮件、手机号、身份证号、性别,防止用户随意输入产生无效数据。

和前两者一样,指望开发人员在代码中对入参格式做合理处理是困难的——瞅瞅自己公司数据库中有多少无效的手机号、身份证号、车牌号就知道了。参数格式需要在产品策划阶段加以定义,并纳入到测试用例中;开发框架需要提供常见格式校验的能力(如邮箱、URL、身份证号等),开发人员只需要简单的配置就可以实现参数格式校验——不是所有的开发人员都会写邮箱验证的正则表达式的。

字符串的第四个威胁是空格。你没看错,就是这么小小的空格,困扰了无数运营和开发。反正我是遇到过多次因小小的空格造成的血案。对于开发来说,去空格这件事卑微到不屑去做;对于运营来说,检查空格不但卑微而且无趣。空格的威胁力在于其本身极其没有存在感,开发很难关注,运营很难发现,但出现问题时很难排查。

我们就遇到过一次支付失败的问题,两边团队查日志、查配置,眼睛都瞎了还找不出问题所在,最后一个偶然的机会,某人发现运营在填 appid 时末尾多了个空格!

不能指望开发人员能自觉地对所有字符串参数去首尾空格,必须在框架层面统一处理。


流程异常:

这里的流程异常不是说代码没有正确实现业务逻辑——那属于功能异常,不属于鲁棒性考虑的范围。这里说的流程异常是指在正常执行流中出现了不可控的异常。

想想我们过去开发的接口,有没有出现过以下情况:

  • 读取磁盘中的文件——有没有考虑读取失败会怎样?
  • 写入磁盘文件——有没有考虑写入失败会怎样(如目录不存在)?
  • 读取系统时间——有没有考虑如果系统时间错误会怎样?
  • 计算某个比率(如中奖率)——有没有考虑除数是 0 的情况(如压根没人抽奖)?
  • 调某个外部接口——有没有考虑接口调用失败(如超时)的情况?
  • 更重要的,当流程中的某一步失败了,其他步该如何处理(以及已经产生的数据如何处理)?

以上异常有两个特征:

  1. 大部分是不可控的(无法通过程序自身避免问题发生);
  2. 只要系统运行时间足够长,就一定会发生(除非系统自身没有涉及到这些方面,如压根没有涉及到远程调用);

健壮的程序要能够正确地处理这些异常,保证数据的一致性。这里有两层含义:

  1. 程序要处理(而不是忽略)这些异常;
  2. 程序能正确地处理这些异常,让程序在发生异常时的行为符合预期;

作为开发人员我们不能有”幸运儿“思想:我的系统不会发生这些问题。但这不代表我们的程序一定能够消化掉这些异常并让流程继续进行下去——有时候让流程终止才是唯一正确的方式,但由于程序没有处理这些异常(或者处理不当)导致流程继续进行,进而导致数据一致性问题(比如在储值卡充值场景中,调支付接口失败,但程序没有判断该异常,仍然往下执行,给用户卡账充了钱)。

处理这些异常的方式主要有以下几种:

  1. 终止执行流。比如储值卡消费场景,如果储值卡扣款接口调失败了,则要终止执行流,防止出现扣款失败但订单状态变成已支付的数据一致性问题(实际上储值卡消费的异常场景远比这里说的复杂,后面我会在单独的文章中分析该场景);
  2. 预处理。比如写文件的场景,可以先判断一下目录是否存在,不存在则先创建目录然后再写文件;计算比率时可先判断分母(如抽奖次数)是否为 0,如果为 0 则比率直接为 0,不再执行除法运算。
  3. 重试。这在远程调用时用得比较多,当接口超时时,一段时间后(如 1 秒)重试一次,还不行则终止执行流。但需要注意,一般接口超时往往意味着对方系统负载高(或者网络拥塞),大量的重试会加重对方系统负担,最终崩溃掉;另外重试也会导致本次请求长时间占用本服务器资源,如果对方系统长时间无法恢复,本系统则会产生大量的请求进程(大家都在那重试),最终引发雪崩。如果决定引入重试机制,则需要合理设置超时时间(比如 2 秒。时间越长请求占用资源越久,越容易导致雪崩),重试次数也不能太多,可能还要结合熔断和限流一起使用。
  4. 异步补偿。对于执行流中的非核心节点出现的异常(主要是远程调用失败的场景),我们可以先做异常登记,然后执行流继续往下执行。而后我们通过异步任务去重试这些异常节点。比如用户消费返券的场景,在支付回调的处理流程中会调券接口给用户发券,如果该接口调用失败(超时),我们除了可采用重试机制,还可以在数据库中(或消息队列中)写一条失败待重试的记录,由异步处理程序稍后重试。
    相比同步重试机制,异步重试不会导致本次请求占用太久服务器资源,本次请求的后续流程仍然能够快速执行完成;另外异步重试的时间间隔可以更长(如 10 秒一次,或者随着重试次数而增加时间间隔),这样对被调用系统的压力也更小。
    不过异步重试也是有限制条件的。首先相关节点可以异步化,后续节点不需要依赖该节点的输出结果;其次业务对该节点的时效性具有较宽的容忍度(如消费返券的场景,即使延迟几秒钟发券也无所谓)。

性能异常:

健壮的接口应具备一定的性能承诺能力——即并发处理能力(在一定并发量——比如 1000 qps——的情况下每个请求的平均处理时间)。

性能问题来自三个方面:

  1. 自身代码质量导致的性能问题;
  2. 所依赖的服务出现性能问题而造成的连锁反应;
  3. 异常调用量造成的额外压力(如大促);

大部分接口的性能问题来自接口自身的实现缺陷——如从不使用缓存、很少创建索引。所以优化接口性能总是要先从缓存和索引着手,这是成本最低、最立竿见影的做法。

有很大一部分的性能问题来自所依赖的服务(接口)。一般有两种解决办法:

  1. 找到对方,让对方优化接口性能(如果是部门内部团队,该方案比较可行);
  2. 将调用异步化;

在接口自身已经达到优化极限的情况下,还承受不了并发压力,说明需要水平扩容了——往集群中再加几台服务器。但现实往往没那么简单,因为性能瓶颈往往出现在存储上而非业务服上,而存储恰恰是最难扩展的部分。

这里不会去讨论怎么设计高并发系统,也不会去讨论熔断限流这些”高级“的话题(其实一点都不高级)——这里要强调的是,在”言必高并发“的今天,对于大部分公司来说,性能优化性价比最高的三剑客仍然是:缓存、索引、异步化

除了这三种异常,其实前面讨论的幂等性也属于鲁棒性范畴,它说的是接口在异常调用的情况下对数据一致性的保障能力。


安全性

前面讲的 XSS 攻击和 SQL 注入也属于安全范畴,不过此处说的安全性是指防止接口被非法调用。

主要有两种类型的接口调用:

  1. 前后端接口调用;
  2. 后端之间的接口调用;

两种调用者的区别是,前端完全暴露在外部(相当于裸体),而后端调用者本身是处于各种保护之中的(相当于穿了羽绒服)。


前后端调用:

前后端的信任是基于登录的(账号密码登录、手机号验证码登录、微信/支付宝 Oauth 授权登录等),用户登录成功后,后端会生成一个登录标识给到前端,前端后续请求后端都会带上该标识。登录标识有两层含义:

  1. 验证前后端交互的合法性:该前端此时能否调该接口。
  2. 验证操作的合法性:本次接口调用是否有权操作其指定的数据(只能操作登录用户权限范围内的数据)。

常用的登录标识有 session 和 token 两种方案。


session 方案:

传统的基于浏览器的 Web 应用多采用 session 方案。用户登录成功后后端生成一个随机串(sessionId),通过 Cookie 传递给前端;前端调后端接口时同样通过 Cookie 将 sessionId 传递给后端,后端校验 sessionId 的合法性,然后执行后续操作。流程如下:

image-20220324161101160

前端调后端接口时由浏览器自动将 Cookie 携带入 HTTP Header 中,而后端 sessionId 的生成与维护一般也由框架底层支持——就是说 session 方案基本是个开箱即用的方案,实在是太方便了(方便到以至于很多人并不清楚 session 的运作机制)。

方便是有代价的。session 方案存在以下几个问题:

  1. 跨域问题。Cookie 默认是不支持跨域的,这对需要跨域访问的站点可能是个问题。当然解决方案也有多种,如将 Cookie 的 domain 属性设置为一级域名;采用 sso。
  2. 分布式访问问题。一般框架默认的 session 存储方案是本地文件存储,这会导致在集群环境登录失效——用户登录的时候在 A 服务器生成的 session,自然存储在 A 服务器本地,用户后续的请求如果打到 B 服务器,由于 B 服务器没有该用户的 session,就会报错。解决方案也有很多种,如采用集中式存储方案(一般采用 Redis,大多数框架也支持一键配置 Redis 作为 session 存储方案);配置负载均衡规则,让同一个客户端的请求都打到同一台服务器。
  3. CSRF 攻击。由于 sessionId 是通过 Cookie 传输的,”浏览器自动将 Cookie 写入 HTTP Header 头“这一做法带来方便的同时也带来了危险——CSRF(跨站请求伪造攻击)利用这一特性可以在别的网站上伪装成合法用户请求实施非法操作。当然我们可以通过 CSRF Token 来防范 CSRF 攻击。
  4. 状态保持。由于 sessionId 本身并不携带用户信息(如 userId),所以服务器端必须将用户基本信息和 sessionId 一同存储起来,如此才能知道该登录会话是由哪个用户发起的。当登录量很大时,这是一笔不小的存储开销。
  5. 移动端环境。有些移动端环境不支持 Cookie,此时开发人员不得不自行实现 Cookie 存储与传输。

上面的情况都是可以解决的——问题在于是不是所有人都解决了这些问题呢?肯定不是的,现实中大量的网站没有做 CSRF 防护,没有将 Cookie 设置成 HttpOnly,没有做 XSS 注入和 SQL 注入过滤。

所以有没有其它方案能够规避掉 session 方案的这些问题呢?

方案是有的,也就是目前业界非常青睐的 Token 方案。


Token 方案:

既然 session 方案的问题都出现在 Cookie 上(具体是 Cookie 的客户端存储和传输机制上),那我们可以对原先的方案稍作改造,让它不依赖于 Cookie。

后端生成登录标识(为了和 session 方案区分,此处我们叫它 token)后,通过自定义响应头(如就叫 Login-Token)将 token 返回给前端,前端将该 token 以适当的方式存储起来(如 localStorage);前端对后端的后续请求都在 HTTP 请求头中带上该 token,后端先校验 token 的合法性,并通过 token 拿到登录用户信息,然后执行后续流程。

和 session 方案一样,Token 也是通过 HTTP Header 传输的(Cookie 也是在 HTTP Header 中),只不过 Token 的存储和传输都是由应用层程序自己控制的,没有利用浏览器的自动机制,CSRF 伪造请求时自然带不上该参数。

由于不需要依赖 Cookie,token 方案也就不存在跨域问题,并且在移动端环境也很好使用。

此 token 方案在服务器端的行为和 session 几乎是完全一致的:它也需要生成一个随机串(token),并且要将 token 串和用户基本信息以适当的方式保存起来以供后续使用。

也就是说该 token 方案仍然需要保存状态信息。如果该状态信息存储在服务器本地,则同样会存在分布式访问问题。

我们并没有解决 session 方案的第 2、4 两点问题。

兵来将挡,水来土掩。

服务器端之所以要存储状态信息,是因为 token 自身没有携带状态(用户)信息——那如果我们让 token 自身携带这些信息呢?

好像可行。比如我们这样生成 token:

// 状态信息(用户信息)
stat_info = 'userid=12345&name=张三';
// 将状态信息 base64 编码后得到 token
token = base64_encode(stat_info);

如此,服务器后续从前端拿到 token 后 base64_decode 就能拿到用户信息了。

可行吗?

当然不行!

服务器端之所以存储 token 相关信息,一方面是为了后面能拿到登录用户信息,另外一方面是为了能够校验客户端传过来的 token 是不是服务器端生成的,而不是客户端自己伪造的(回想一下前面提到的”登录标识“的两层含义)。

现在服务器端没存 token 了,怎么检验前端传过来的 token 是否有效?

别气馁。如果我们能够让前端伪造不了呢?

所谓伪造,跟”篡改“是一个意思。业界防篡改的常用手段是签名——对,我们给刚才生成的 token 加上私钥签名:

// 签名秘钥(从配置中心获取,或者脚本定期动态生成)
key = 'ajdhru4837%^#!kj78d';
// 状态信息(用户信息、登录过期时间)
stat_info = 'userid=12345&name=张三&expire=2022-03-25 12:00:00';
// 将状态信息 base64 编码
encode_info = base64_encode(stat_info);
// 签名(此处用 HMACSHA256)
sign = hmac_sha256(encode_info, key);
// 将 encode_info 和 sign 签名拼在一起生成 token
token = encode_info + "." + sign;

如上,我们得到的 token 串长这样子:xxxxxxxxxx.yyyy,其中 x 部分是用户信息 base64 编码后的值,y 部分是对 x 部分的签名。

有了 y 部分的签名,外部由于没有签名秘钥,便无法修改或者伪造 x 部分的内容了。

这个带签名的无状态的 token 业界有个标准方案叫 JWT。


JWT:

JWT 是 JSON Web Token 的缩写,是 RFC 7519 定义的鉴权和信息交互标准。

从名字可知,它是用 json 格式存储信息,主要用于 web 接口交互(但不限于前后端交互的场景),在系统间(前后端、后端之间)接口交互时实现鉴权和非敏感信息传输。

先看看 JWT token 到底长什么样子:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

看到这串”乱码“中两个小小的点(.)没?它将这段字符串分成三个部分:

xxxxx.yyyyy.zzzzz

第一部分(x)和第二部分(y)都是 json 字符串的 base64 编码(JWT 的 J 就是 json 的意思)。具体地,第一部分叫首部(Header),放一些元数据(签名算法等);第二部分叫有效载荷(Payload),放的是具体要传输的信息;第三部分(z)是第一部分和第二部分的签名串,防止前两部分被篡改。

我们对上面 token 的前两部分 base64_decode 看看里面是什么东西:

// 第一部分 decode 后
{
  "alg": "HS256",
  "typ": "JWT"
}

// 第二部分 decode 后
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

第一部分(首部)包含了类型(typ,此处是 JWT)和签名算法(alg,即用什么算法生成第三部分签名串,此处用的是 HMAC_SHA256);第二部分(有效载荷)可以自己定义(如上面的 name),RFC 标准定义了一些通用的字段(如上面的 sub、iat)。

你有没有发现,任何人都可以查看前两部分的内容?

是的,JWT 前两部分是明文,所以不要放敏感信息(你也可以对前两部分加密,但一般我们不这么搞)。JWT 的真正用途是签名而不是加密。

现在我们用 JWT 来实现前后端无状态交互。

JWT token 生成过程如下:

// header
// 签名算法也用 HS256(HMAC_SHA256,编程语言一般都提供了相应的算法库)
header = '{"alg": "HS256","typ": "JWT"}';

// payload
// 定义了三个非敏感信息:用户编号、姓名、token 过期时间
payload = '{"user_id": 123456,"name": "张三","exp": "2022-03-25 12:00:00"}';

// header base64 后
base_header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
// payload base64 后
base_payload = "eyJ1c2VyX2lkIjoxMjM0NTYsIm5hbWUiOiLlvKDkuIkiLCJleHAiOiIyMDIyLTAzLTI1IDEyOjAwOjAwIn0";
// 两者拼接
content = base_header + "." + base_payload;

// 签名秘钥(从配置中心获取,或者后台脚本定期刷新)
key = "ajdhru4837%^#!kj78d";
// 用 HMAC_SHA256 签名
sign = hmac_sha256(content);

// 得到最终的 token
token = content + "." + sign;

张三登录成功后,后端将上面生成的 JWT token 通过 HTTP 响应头(假如叫 Authorization)返回给前端,而后前端请求后端都会带上如下 HTTP Header:

Authorization:<jwt_token>

后端拿到前端传的 token,先对前两部分计算签名,和第三部分比较,如果一致,说明该 token 合法,并从从有效载荷中解析出用户信息。

后端并没有存储 token,完全是从前端传过来的 token 中解析出用户(状态)信息,一方面避免了后端存储的开销,同时也解决了集群服务的访问问题,堪称完美!

我们在有效载荷中增加了过期时间(exp),该 token 只在该时间之前有效。

这里有个问题。我们假设用户是在 2022-03-25 11:00:00 登录的,登录有效期是 1 个小时,即 token 的过期时间是 2022-03-25 12:00:00。假设用户在 2022-03-25 11:59:58 访问某个页面,此时 token 未过期,能正常访问;用户在该页面停留了 2 秒钟,然后点击某个按钮,此时 token 过期了,后端会返回”登录过期“错误,前端就会跳转到登录界面——你能想象此时用户心里有多少只马在奔腾吗?

所以和 session 方案一样,必须要有 token 刷新机制,保证在用户频繁操作的情况下,token 不会过期。

JWT 的 token 刷新机制很简单,我们验证前端的 token 没问题后,检查一下有效期,如果过期了,那自然就返回错误;如果没有过期,我们会根据当前时间生成一个新的 token 给到前端,前端用这个新 token 替换掉原来的 token 即可。后端在每次接口响应头部都加上:

Refresh-Token: <new token>

如此,用户只有在 1 小时内没有任何操作的情况下才会退出登录。

无论是采用何种方案,有一点需要记住:前后端通信一定要使用 https,否则在登录之初就已经不安全了。


后端之间的调用:

后端相较于前端的一个优势是,后端双方都可以持有秘钥。根据数据敏感度不同,有两种不同级别的保障需求:

  1. 防篡改。对于一般的数据,只需要保障数据在传输中不会被篡改即可。此种场景可采用签名方案(如基于 RSA 的签名、基于 secret 的签名);
  2. 防窥视。一些敏感性数据,不但要防篡改,还要防止被非法接受者查看,此时需要采用加解密方案(如采用 RSA、AES 加密);

一般来说,非敏感接口只需要签名即可(防篡改,同时也起到调用方身份认证的功能),而敏感数据则需要签名+加密。

数字签名方案需要双方事先协商秘钥(secret);非对称加密方案需要事先协商公钥私钥对。这里不详细讲解两种方案的具体实现细节,主要提一下很多人在设计接口鉴权时都忽视的一种风险:接口重放攻击。

比如服务器 A 调服务器 B 接口:

https://www.b.com/somepath?name=lily&age=20

对请求参数使用秘钥签名后:

// 签名算法由 B 决定。如 md5(join(ksort(params)) + secret)
https://www.b.com/somepath?name=lily&age=20&appid=12344&sign=a8d73hakahjj2293asfasd234431sdr

这便是 A 调 B 的完整请求参数。

服务器 B 接收到请求后,使用同样的秘钥和签名算法对请求参数(sign 除外)进行签名,发现和传过来的 sign 一致,便认为是合法请求。

有什么问题吗?

一年后,只要双方的 secret 和签名算法没变,上面这个 url 仍然是个合法请求——这是个永不失效的签名。

一般为了排查问题,调用双方一般都会把请求信息记录日志,如果日志内容遭泄露,里面所有的请求都能被重放。

所以我们必须让签名有个有效期,过了一定的时间后原来的签名就自动失效了。

我们在请求参数中加入请求时间,B 接收到请求后,先判断该时间跟 B 的本地时间差是否在一定范围内(如 5 分钟),超过这个时间范围则拒绝请求(当然这要求双方服务器的时间不能错得离谱)。这样就相当于签名只有 5 分钟的有效期,大大降低被重放的概率。

// 带上时间戳,服务 B 先检测 timestamp 值是否过期
// 由于 timestamp 字段也被纳入到签名参数中,调用方无法修改 timestamp 的值
https://www.b.com/somepath?name=lily&age=20&timestamp=1647792000&appid=12344&sign=8judq67kahjj2293asfas5dh1k93

除了签名和加密,还可以结合其他方面加固接口的安全性,如对外接口(非局域网调用)必须使用 https,采用 IP 白名单机制等。


后记

接口设计除了上面提到的幂等性、鲁棒性和安全性,还有其他很多值得探讨的东西,包括接口的易用性、返回参数结构的一致性、前后端协作方式等,不一而足。

好的接口设计并不是个人的事,而是团队的事:

  1. 要尽可能地将保障能力前置(前置到框架、运维层面),让具体开发者要做的事尽可能少。没有谁能保证自己写的所有接口的所有方面都处理得面面俱到——这个参数忘了去空格,那个参数忘了做 XSS 过滤。更何况一个接口往往不是由一个人开发和维护的。
  2. 需要有质量审查机制。如果有可能,由测试团队给接口做渗透测试和性能测试。代码审查(以及工具审查)也能发现一部分问题。
  3. 需要强化团队成员的相关意识。如防御性编程、充分利用缓存和索引、异步化编程,这些往往是意识问题。
  4. 选择合适的开发框架。需考察框架对 XSS、CSRF、SQL 注入、格式校验、签名、队列、调度等的支持情况和上手难易度,以及团队成员的熟悉度——如果一部分人不熟悉,则要组织培训。



posted @ 2022-03-25 09:40  林子er  阅读(2664)  评论(4编辑  收藏  举报