接口的一般性问题
很多程序员开发接口的时候,往往仅关注功能实现,但决定接口质量的恰恰是非功能性方面——遗憾的是,这一点在很多公司,从项目到产品到研发,甚至到测试,都未得到应有的重视。
接口的非功能性要素主要体现在如下几个方面:
- 幂等性;
- 鲁棒性;
- 安全性;
幂等性
如果某一天你在超市消费了 1000 元,而你的银行卡被扣了 2000 元,你是什么感受?
(当然你我几乎不会遇到这种问题,因为金融级别软件出现这种低级错误,估计是不想在市面上混了。)
重复扣款涉及到接口的幂等性问题。
幂等性是指写型接口必须保证重复调用时的数据正确性,一般出现在添加数据的场景,以及一些非幂等修改的场景(如扣减余额)。删除场景一般具备幂等性。
我们无法预期接口调用方如何调接口,可能由于调用超时,或者调用方实现问题(比如前端用户可短时间内高频点击),接口设计必须将重复调用作为常态考虑——因接口被重复调用而导致数据问题,责任应归于接口实现者而不是调用者。
处理幂等性的手段一般分业务逻辑层面和数据库层面。
业务逻辑层面:select + insert:
这种方式应用得很多,实现方式是在添加或修改数据之前先根据请求参数(如用户编号、订单编号)查一下相关数据,以决定该请求是否已经处理过了,防止重复处理(如重复加积分、重复扣款)。
这种处理方式的优点是它本身属于业务逻辑的一部分,产品和开发人员画流程图时往往会自然而然地包括这些逻辑,因而也是最容易想到的实现方式——容易想到就意味着现实中大部分的系统已经实现了这种基本的幂等性处理。
但这种 select + insert 解决不了并发问题:在极短的时间内发生的重复请求,比如用户疯狂地点击按钮(假如按钮没做任何限制)、羊毛党薅羊毛等。
在高并发时,同一个用户的两个请求几乎同时到达,此时两个请求几乎同时 select,都发现数据库没有相关记录,于是都能执行后续业务逻辑。
所以对于重要场景(如发券、积分等),请求必须在用户级别具有排他性:同一时间同一个用户只能有一个请求在处理,多个同样的请求必须串行处理。
我们可以借助 Redis 来实现分布式请求锁。根据相关请求参数生成 redis key,比如在增加积分场景,可以根据“用户 id + 场景 id” 生成 key 作为锁,请求到来时先检查锁是否存在,如果存在则直接拒绝处理,不存在的话才进入下一步。这样就保证了请求的排它性。流程图如下:
然而,当你的数据库使用读写分离时,你会发现请求锁方案有时还是会出现漏网之鱼。业务系统处理完成后会解除请求锁,此时同一个用户的重复请求就可以进来,但此时新数据可能还没有同步到从库,因而 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 小时学会。)
数据库层面:
我们可以通过数据库提供的唯一键约束来实现幂等性。
我们看看储值卡扣费场景。电商的储值卡支付场景中,储值卡扣费环节至少要发生两个操作:
- 产生一笔流水,至少包含订单号和支付金额;
- 储值卡账户扣除相应金额;
如果储值卡支付接口不做任何幂等性处理,那就有可能同一笔订单会产生两笔支付流水且卡账户被重复扣款,造成客诉。
这里我们除了可以采用前面的“请求锁+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 号段。
流程如下:
总结一下如何用数据唯一键实现接口幂等性:
- 适用于插入数据的场景,典型的如“流水+总账”模式的业务(如储值、积分、点赞等)。
- 优先使用业务字段本身实现唯一性约束,比如储值卡消费流水中的订单号。或者是若干字段(2、3 个)的组合键唯一约束,如点赞场景。
- 当没有业务字段做唯一约束时,可创建单独标识字段做唯一约束,此时由调用方提供唯一标识符。
- 需保证调用方标识符的唯一性,可采用业界标准的 uuid、snowflake 算法,也可以自己实现。标识符可以由调用端自行生成,也可以由发号器统一生成,根据自己的实际情况和并发量做决策。
- 发号器的实现必须考虑其可扩展性,需保证发号器集群生成的标识具有唯一性。
- 数据库唯一键约束可能会和请求锁、“select+insert”方案一起使用。
关于接口幂等性还有个需要关注的问题:当服务提供方发现本次调用已被处理(本次可能是调用方超时重试,也可能是其它异常调用),应该返回什么?
有些开发者想当然地从业务判重角度将重复操作作为异常场景看待,不假思索地返回个错误码,这会给调用端带来困扰,很可能带来数据完整性问题。
此时最简单的做法是直接返回 OK——如果开发团队中只有一种状态码表示“成功”的话(如 code=200)。
有些开发团队借鉴 HTTP 状态码的定义,将 20X 状态码段定义为成功码,此时可以就“操作成功”和“该操作已处理过”定义不同的状态码(如 200 表示成功,201 表示该操作已处理过),这样既不干扰调用端的业务处理,也能让业务端确切知道本次调用的实际处理情况。
前后端的幂等性:
考虑下面的场景:
张三在管理后台创建券,点击“创建”按钮后半天没响应(网络较慢),于是张三又连续点了若干次,结果去列表一看,创建了三四张券。
当然你我第一反应很可能是在前端做交互优化:点击按钮后将按钮置灰,并提示“正在创建中...”,直到后端返回数据后按钮才可以再次点击。
上面的前端交互优化确实可以解决绝大部分重复创建的问题。
不过,试想一下这样的场景:
用户点击创建按钮后,后端服务处理较慢(如服务器负载高了),前端按钮置灰,用户不可点击。
过了一会(如 5 秒钟),前端接口等待时间超过阈值,前端 js 直接报超时错误,告知用户“服务处理超时,请稍后重试”。
于是用户再次点击“创建”按钮。
然后,用户去券列表页面,很可能会发现自己创建了两张券。
问题出在当前端发现后端接口超时后,会认为事务处理失败,于是提示用户重试,但后端事务实际上仍在执行(甚至有可能后端事务其实早都执行完了,但在返回数据时出现了网络问题而超时),此时用户再次点击“创建”按钮实际上会执行两次事务(创建两张券)。
所以在前后端调用的场景中(主要是创建型事务的场景),同样需要通过唯一标识(如 uuid)来保证接口调用的幂等性。
首先我们想到用类似前面“请求锁”方案(但这次不是加锁):
在渲染创建页面的时候,后端生成一个唯一标识符 X,将其保存到 Redis 中(设置一个合理的有效期),并将该标识符返回给前端;
前端请求后端“创建优惠券”接口时,带上该标识符;
后端先比较该标识符是否和 Redis 中的一致,标识符没问题才进行后续的事务处理;
后端事务处理成功后,删除掉 Redis 中的标识符;
前端在使用该标识符请求后端,后端由于检测不到该标识符,会直接返回错误;
流程如下:
上面的流程有没有问题呢?
它确实能阻止一部分重复提交,但不是全部。
试想前端请求后端接口,后端接口超时了(但实际上后端事务仍然在执行中),此时前端会让用户重试,用户再次提交,这第二次接口请求仍然会带上刚才的 flag,那这次 flag 校验是否会通过呢?可能会,也可能不会,取决于第二次请求到达时,前一次的事务有没有处理完(从而删除掉 flag)。假如前一次的事务(这里的事务不是说数据库事务,而是指该接口要做的事情)还没有处理完,那么这个 flag 就仍然是合法的,那么第二次请求仍然会被处理。如下图:
我们也不能在接口处理完之前删除掉 Redis 中的 flag,因为如果事务处理失败,是需要前端重新提交的。
要想前后端交互真正的实现幂等性,必须借助数据库的唯一键约束。和前面的一样,我们给数据表增加一个专门字段(假如就叫 flag)做唯一性约束,我们以券为例,数据表大致长这样:
这里的 flag 就是上面我们生成并存储到 Redis 的那个唯一标识,我们在数据库插入券数据的时候一并写进去。由于 flag 字段是唯一键,如果先前已经写入过了,再写入就会报唯一键冲突错误,写入失败,从而保证了接口的幂等性。如此,上图中用户再次点击提交,虽然flag 校验仍然会成功,但两次处理只有一次会真正成功,另一次在写数据库时会失败(不能保证一定是第一次请求写入成功,网络调用不具备时序性)。
加上数据库约束后两次请求的处理过程如下:
有人可能觉得有了数据库层的唯一性校验,就可以去掉 Redis 那一层的校验。这是不行的,如果去掉 Redis 这层校验,我们便无法保证前端传的这个 flag 是我们自己生成的,也就是说前端随便传个 flag 就能写库了。
总结一下前后端接口调用的幂等性实现:
- 通过前端 js 限制用户高频次点击导致的重复提交,这是成本最低、最快见效的实现方式;
- 通过 Redis 实现标识符校验,结合前端 js 控制,能够满足大部分的幂等性要求;
- 再加上数据库层面的唯一键约束,能够真正实现前后端交互的幂等性;
讲完幂等性,我们看看第二个接口设计原则:鲁棒性。
鲁棒性
“鲁棒”这个词真的误人子弟,反正我第一次听到这个词时脑海中冒出的是一个粗鲁的大汉挥舞着棒子不知在干啥。
“鲁棒”是音译,英文叫 Robustness,翻译过来是“坚固性,健壮性”的意思,所以接口的鲁棒性是指接口的健壮性如何。
接口的鲁棒性取决于它对异常场景的承载能力。
什么样的接口不具备鲁棒性呢?如果一个接口严重依赖于外部输入的合法性以及第三方服务的正确性,一旦外部输入非预期内容(如含有 SQL 注入的字符串),或者所依赖的第三方服务(接口)崩溃了(如超时),该接口就会出现各种未知问题(最典型的是数据一致性问题,如卡账扣款了但订单还是未支付状态),那么我们说该接口是脆弱的,不具备鲁棒性。
几乎所有的程序员都能写出可用的接口(实现正常流程),但至少有一半(其实不止)的程序员写不出健壮的接口。
这里的异常主要包括:
- 输入异常;
- 流程异常;
- 性能异常;
输入异常:
“不要信任外部输入”是常识,但不是所有人都正确处理这块。这里主要包括以下几块:
- 参数类型限制;
- 缺省参数处理;
- 恶意输入的拦截;
考虑到接口调用方编程语言的异构性以及其他复杂因素,参数类型尽量只使用数值类型和字符串,尽量不要用 bool 型(true、false)、Null——有些情况下对方可能给你传的是字符串“true”而不是 bool 值 true,如果你打算用这些类型,请在接口内部消化掉字符串 "true"、"false"。
接口参数应遵循”最小化输入“原则,即调用端只需要关心他关心的参数,接口自身应能正确处理参数缺省值。我见过有些接口有二三十个参数,每个参数都是必填的——调用端对不需要的参数必须传缺省值(0 或空字符串),对接的人一边对接一边崩溃,还经常因某个参数传入错误导致接口报错。
异常输入这块重点在字符串类型上。
字符串的第一个威胁是 XSS 攻击。企盼每个开发人员对每个入参都做脱敏处理是不现实的,所以这一步必须在开发框架层面提供支持,控制器中拿到的参数应该是已经做过处理了的。虽然这是件很基础(基础到不值得拿出来一说)的事情,但我敢保证,市面上有一半的系统都没有做严格的参数处理——因为保证这点的唯一手段是将渗透测试作为测试的一个环节纳入到工作流程中,但大部分中小公司的产品并没有做渗透测试。退而求次,保证接口入参健壮性的次要手段(但对于大部分中小公司是最实用的)是将参数处理纳入到框架层面(有些框架天然支持这点,有些则需要定制开发)。
XSS:跨站脚本攻击(Cross Site Scripting,为了不和层叠样式表的缩写冲突而写成 XSS),是指恶意用户通过在网站中注入 javascript 脚本实现攻击(如获取 Cookie 信息)。
比如我们网站有个输入框(普通文本框或者富文本),用户在里面输入”“,如果后端接口没有对该输入做任何处理就存入数据库,那么当这段文本在前端页面渲染时该脚本就会被执行获取到 Cookie 信息。
那是不是把代码里面