服务治理——幂等

幂等性描述一项操作被执行多次后,不会改变第一次执行产生的副作用。
练手项目,解决项目中的幂等性检查要求,代码地址:point_right: Github - TIdempotent

  • BlockingChecker、NonblockingChecker ;
  • 加入 MySQL 作为 KeyStore (JdbcKeyStore);
  • 加入 Redis 作为 KeyStore (RedisKeyStore);
  • 只存储正确的返回结果,返回值压缩 + 缓存;
  • 加入 Spring 便于整合进业务系统;

〇、初衷

幂等性检查在非幂等的远程调用中几乎是必须的,而且这样的场景很多,一般我们都是直接在业务代码里写上,但很多时候容易用错中间件,或者是没有考虑到一些极端条件、在代码中埋下隐患。
在抛砖之前,我们先来分析一下幂等功能的大概实现模式:

  1. 每次请求都在中心化的数据库上保存这次请求的 id;

    如何标识一次请求?

  2. 因为存储容量限制,我们不会放任数据堆积到数据库中,而是设置一个时间戳,每隔一段时间清理掉老的数据;

    如果数据库支持的话,可以直接设置数据的过期时间。

  3. 之后就可以根据数据库中是否有这个 id 来判断是否已经执行了这个调用。

这种实现比较简单,有时候和接口重试的初衷是冲突的。一般情况下我们实现幂等检查时,会将对应<业务+数据 id>拼接起来存到缓存中间件中,我们姑且称之为 IdpKey,后续请求会查到该 IdpKey,发现已经被调用过了,就直接返回之前执行过的结果。但是这种思路存在漏洞,如下图所示:
幂等检查和接口重试之间的冲突
IdpKey 是只有在幂等检查结束后才会被保存下来的,如果下游服务还没执行完毕,触发上游 RPC 的超时重试机制,就会重新再发一次请求,这时如果上一次请求,仍然没有执行完毕,就会导致请求被执行了两次。
这里的漏洞是:进入下游 API 入口处的幂等检查逻辑,会经过查 IdpKey -> 保存 IdpKey -> 设置超时时间这个过程,可能会因为网络抖动而花费特别长的时间。如果超时是因此而导致的,幂等性检查就起不到作用了。
解决的办法是保证幂等检查的原子性,并且还需要注意存储的隔离性,这在一般的存储设计中是必须要考虑的。

在这里吐槽一下我公司的实现,采用的是setnx + expire的方式,如果setnx后、expire前出错了,之后对该接口的重试也会直接被拦截了,也就是说幂等检查组件影响了正常的业务执行流程。

一、使用时机

服务 API

接口幂等性是在设计 RPC 时必然被提到的话题,因为远程调用为了减少突发性的网络抖动影响、尽可能提高一次请求成功的几率,会在失败后重试几次,但是服务消费者此时其实并不知道服务提供者是真得没接收到请求、或者只是响应在中间走丢了、亦或者是别的什么情况,所以服务框架普遍会在服务提供者处设置幂等性检查。一般情况下有以下几种策略:

  1. 和业务逻辑耦合,比如是涉及订单的业务,订单会有状态,下单后状态会相应的改变,所以我们只需要在业务处理开始的地方检查一下状态就可以起到幂等性检查的目的;
  2. 提取幂等性检查的逻辑作为框架的一部分,使用注解等方式来为目标接口添加幂等检查功能,可以有效减少冗余代码,不过执行效率会低一些;

HTTP 协议的幂等性

根据协议规定,PUT 等方法天生具有幂等性的语义,因为 PUT 会覆盖数据而不是在原来数据的基础上增加,发一次和发多次请求某一资源应该会产生相同的副作用。
道理我们都懂,但是要在幂等检查中特别关注并忽略掉这些情况又不大适合,因为根据我看的大部分代码的感受而言,大家基本上不会特别严格地遵守 REST 协议(或者说风格?):每个接口都讨论一遍怎么套用各 HTTP 方法来解释太影响效率,所以大不了读的就用 GET,写的就用 POST,以及链接中随意使用动词、返回状态码等。因此我认为不特地地根据 HTTP 方法进行过滤也没有关系。

WEB API

API 有时候也会考虑“幂等性”,但这严格来说不属于我们这里讨论的范畴,我们主要关注 RPC 中重试的幂等性,这里只是稍微提一下。比如在确认下单的时候用户多点了几下(还包括刷单、 DDoS 攻击等),后台就会收到同一用户在很短的时间内发来的多个相同请求,除了在网关层限流,往往后台还会对用户的请求频率进行限制,比如使用从请求上下文里得到的用户登录 token(唯一标识一个用户)和订单 ID (唯一标识一个业务)拼接一个 IdempotentKey (以下简称 IdpKey ),保存到缓存中间件并设置过期时间:

代码
1
2
3
4
5
6
7
8
9
10
11
12
// 判断用户是否刚下过单
private boolean justOrdered(String orderId) {
String idempotentKey = UserContext.getUserId() + orderId;
// setIfAbsent接口封装了Redis的setnx命令,当key不存在时设置成功并返回true,否则返回false
if(redisClient.setIfAbsent(idempotentKey, "true")) {
// 设置key的过期时间
redisClient.expire(idempotentKey, 3, TimeUnit.SECONDS);
return false;
} else {
return true;
}
}

这段代码有问题,因为 setIfAbsent 和 expire 是分成两次请求的、并不是原子的,如果在 expire 出错,就会导致缓存项成为“死数据”,解决办法是利用 set 命令就够了,set 命令有很多参数,其中就包括设置原子性和设置过期时间的。

写数据库

产生幂等问题的根源是数据库写操作执行了多次,所以一种最简单的实现方法是为表加唯一索引,并使用on duplicate key update语句来保证幂等性,假设user表中的name字段加了索引:

1
2
3
4
5
6
7
8
9
insert into user (
name,
pwd
) values (
'Mike',
'123456'
)
on duplicate key update
pwd = #{pwd};

当然,如果表中没有这种唯一索引,这种方法就不好使了。

二、设计思路

如何标识一次请求

IdpKey 需要能唯一标识一个请求,服务 Producer 在重试时,需要将这个标识符带上,这样就可以在服务 Consumer 的调用上下文找到这个标识符,从而唯一确定一个请求。
实行服务链路追踪功能的组件一般称为 Tracing 组件,Tracing 组件中有两个基本工作单元 TraceId 和 SpanId,我们先来讨论一下一种开源实现——Spring Cloud Sleuth 能否满足我们的需求:

  • TraceId 唯一标识一条请求链路,主要用来追踪链路执行情况;
  • SpanId 表示一个基本的工作单元,比如一次消息发送、Http 请求,当然也可以简单理解成一次远程调用。重试时会将刚开始生成的 SpanId 重发几次,所以 SpanId 是合适的。

    如何传其实还是由具体实现说了算,我看到的另一种实现是:对于基于消息的 RPC,这个 SpanId 会加到 Message 内,对于基于 HTTP 的 RPC,这个 SpanId 会加到请求头部。不管是哪种方式,每个 request 对象只会生成一次,然后重试时使用该 request 重发几次。

还有一个合理但是略丑的方式是在请求参数中强行添加上一个InvocationId,但是这样代价就太高了。

注意事项:
Consumer 接口不应该是递归的,递归会使得一个 IdpKey 被不必要地重复检查、导致链路意外中断。

IdpKey 的存储非常灵活,可以将其保存到全局的并发安全容器内,也可以选择其他的存储中间件,如果有较强的一致性需求,最好使用 MySQL 等数据库中间件,并将 idp 数据库和业务数据库建到同一个服务器上,这样它们才能受到同一个事务管理。

远程调用的发生是非常频繁的,随着服务器的运行, KeyStore 中保存的 IdpKey 也会占用非常可观的内存或磁盘空间(根据具体实现而定),所以需要定时地去清理不用的数据。一般请求的重试过程不会持续太久,频繁的全表扫描也会一定程度上拖慢服务器的性能,所以 10 分钟清理一次我认为是比较合理的。一般 NoSQL 数据库都会提供为保存的 key 设置过期时间的功能,而 SQL 数据库(如 MySQL )则需要在服务器里另起一个线程来执行清除操作。

实际上,只要具备链路追踪功能的框架都可以作为替代,比如Spring SleuthZipkinSkyWalking

接口执行状态

sidp-状态扭转

  1. BlockingIdpChecker 主要用于异步调用,这种场景下希望请求能够尽可能成功。
  2. NonblockingIdpChecker 可以用于同步调用的场景下,调用者重试意味着放弃了之前发送的请求,没有必要在上个请求仍处于 EXECUTING 状态下时阻塞等待其执行完毕。

生成 IdpKey 或持久化 IdpKey 时可能发生异常,一般这两个过程是比较稳定的,万一出现问题,会影响实际业务的执行,它们的可用性需要采取其他措施来保证(一般是集群化)。
BlockingIdpChecker 中在发现存在其他线程正在执行该 IdpKey 对应的请求时,会阻塞,每隔 50ms、100ms、200ms 轮询数据库,使用这几个值的原因是我在实际中发现大部分接口在正常情况下都能在 50ms 内处理完毕(要么执行成功要么抛出异常),部分比较重量级的接口基本也能在 200ms 内执行完毕。如果在这段时间内没有处理完毕,其实继续等待下去意义也不大了。
详细描述待定…

结果缓存+压缩

幂等性的定义中描述操作的多次执行只会产生一次副作用,这意味着每一次都可以得到相同的结果,为了在后续执行中可以返回第一次成功时产生的返回值,我们需要对结果进行缓存。
与缓存系统的交互一般通过 TCP 连接来传输,在代码中,我使用 Json 来序列化数据,保证数据的可读性,然后用 GZIP 算法来进行压缩,提升传输效率。

SQL 数据库

当使用数据库来持久化 IdpKey 时——以 MySQL 为例——需要创建一个 idpkey 表来保存 IdpKey ,建表语句如下所示:

1
2
3
4
5
6
7
8
create table if not exists idpkey
(
`id` varchar(36) not null,
`key_state` char(12) not null default '',
`created_time` timestamp not null default now(),
`content` blob null,
primary key (`id`)
);

该表非常简单,但是还是多说两句为什么要这么建,特别是索引的考虑。

  1. id 为什么是 varchar(36)类型?
    36 是 UUID 的长度,一般不会有比这个更长的了,但是不确定会不会使用别的计算方式,
  2. 为什么 key_state 使用 char 而不是 varchar?
    因为该字段值长度稳定,不能很好发挥变长字符串的威力,而且更新频率高,当字段值变长时,可能需要存储引擎做额外的工作(比如分裂页等),总而言之,varchar 更适合存字段值长度变化大、更新频率小的。
  3. 为什么 key_state 字段没加索引?
    状态的取值只有固定的几个,没必要加索引,一方面字段选择性小(只取几个值),数据库无法有效过滤行,另一方面在 key_state 索引中查出大量行后数据库还需要回到聚集索引中“反查”到其他字段值,反查得过于频繁可能需要置入过多的磁盘块。
  4. 为什么 created_time 不加索引?
    created_time 标识一条数据的创建时间,清理的时候会执行一个范围过滤的删除,其中的大部分数据都会被直接删除掉,如果加了索引,虽然匹配速度会稍微快一点,但是数据量大(接近一半)又导致回表和重建索引的过程非常耗时,甚至还不如全表扫描,这可以通过建测试表来验证。
  5. 为什么 content(结果缓存)是 blob 类型的?
    因为结果被压缩成了,保存的不是原文而是字节数组。

KV 数据库(RedisKeyStore)

代码里使用 Redis 作为数据库实现了一种 idpKey 的存储策略,Lua 脚本的语法并不难,难的是如何以 Redis 环境为基础进行调试,具体逻辑见 RedisKeyStore。这里来分析一下为什么要用 Lua 实现?

  • Redis 自带的命令很多,但还是不能覆盖所有的使用场景,比如我在代码里需要实现“putIfAbsent + expire + 返回覆盖前的值”的复杂逻辑就没有原生命令支持。
  • Redis 提供的 Lua 机制可以保证隔离性,因为 Redis 本身是单线程模型,天然保证了串行化执行,即间接实现了串行化隔离级别,而不管是 Lua 脚本还是事务,都是通过批量执行任务来保证这种隔离性的。

    Sentinel 集群可以保证只有一个 master 能执行写命令,因为 Sentinel 服务器本身不支持任何写命令,而 slave 又被设置为了只读。
    Cluster 集群会将 key hash 到某个分片上,同样也能避免了竞争。

  • 但 Lua 本身不能保证原子性,Redis 也没有undo log这一说,如果执行到一半出现宕机(Crash)或者数据漂移也可能会出现数据不一致的问题。因此,如果 Lua 脚本中执行的是set+expire,则有必要在启动的时候进行一个清理动作:删除服务器上旧的 IdpKey。

    一般不会使用set+expire实现加缓存、加锁等操作,而是使用一条带缓存时间的 set 命令。

  • 虽然 Lua 不能满足原子性,但是它最小化了网络的影响,它将所有需要调用的命令和逻辑打包传到服务器上执行,而且只需要上传一次,之后可以通过 SHA 值来指定服务器上的一个脚本。

    Redis 内置 Lua 脚本的调试方法:Redis Lua scripts debugger
    其实除了 Lua 脚本,还可以通过watch机制(类似 ZooKeeper 中的 Watcher)实现乐观锁来取代,优点是简单、不需要维护 lua 代码,但缺点是需要轮询、效率更低。
    或者通过 Redis 的事务机制来实现,Redis 会将一个事务范围内的所有命令打包发到服务器上,直到 exec 才会批量执行,具有和 lua 脚本类似的特性,但是上传多条命令有一定时间损耗,需要权衡使用。

如果存储服务器宕机重启?数据漂移

可用性是分布式系统的主要关注点之一(CAP),当

  • 重启,像 MySQL 这种传统关系型数据库,底层使用文件存储,搭建主从集群后,基本不需要担心数据丢失,而 Redis 这样的内存数据库,待定…。

    也不能完全相信操作系统,因为为了效率,数据会被先保存到高速缓存,并以某种频率同步到磁盘上,数据会先被保存到高速缓存,这提高了写效率,也隐含了数据丢失的隐患。

  • 分库分表的时候需要先将原表按某种策略拆到多个表,再按需要拆到多个库中,这个过程就叫数据漂移,待定…。

退一步讲,重启和漂移只会在很少的情况下发生,如果只遵循最基本的BASE,让大部分请求可用,万一不小心碰到了一些请求重试了也不可用的情况,用户也只需刷新一下即可。

三、耦合

Spring AOP

幂等性检查的场景比较多,属于比较典型的横切关注点,适合采用 AOP 的方式进行简化。

多种 IdpKey 生成方式

正如之前提到的,IdpKey 的 ID 属性可以用 Tracing 组件的 SpanId 来代替,是因为一般链路追踪组件中, 在每次重试时附带的 SpanId 都是同一个,当然是否合适还是要看具体的实现。

多种 IdpKey 保存方式

KeyStore 中定义了几个我认为必须原子化的操作(putIfAbsent、putIfAbsentOrInStates),如果能在持久化层实现这些操作的原子性当然最好,这样就不用额外添加分布式锁了。
我提供了两种实现:

  1. SQLKeyStore 中主要通过数据库的行锁来实现。
  2. RedisKeyStore 中主要通过 Redis 的 Lua 扩展来实现。

与 Tracing 组件如何配合

与Tracing组件配合后的交互流程
以代码中的幂等检查器 BlockingIdpChecker 为例,上图是与 Tracing 组件配置后 BlockingIdpChecker 的大概工作方式。
详细描述待定…

四、并发控制

为什么使用行锁+version(SQLKeyStore)

在 SQLKeyStore 中实现一些复合操作时,为什么不使用乐观锁,而是用行锁+version 的方式?
具体的伪代码如下所示:

1
2
3
4
5
start transaction;
select id, key_state, version from idpkey where id = #{id} or key_state in (#{state1}, #{state2}, ...) for update; -- 锁数据
...
insert into idpkey (id, key_state) values(?, ?) on duplicate key update key_state = ?; -- 更新锁住的数据
commit;

如果是乐观锁则:

1
2
3
select ..., version from idpkey where id = #{id};
...
update idpkey set id = #{id} and key_state in (#{state1}, #{state2}, ...);

这里 key_state 其实起着类似乐观锁的作用,因为只有在某些状态下才能更新成功,但是会有点问题:

  • idpKey 有三类状态:pass(由具体规则而定), blocking(相当于 EXECUTING), reject(由具体规则而定,但是都会包含 SUCCESS),当为 pass 状态时是可以更新的,但是更新失败时可能是 blocking 或 reject 的,必须要把更新时数据库里的值返回回来判断,所以要使用行锁来同步其他线程,实现一种类似 swap 的原子操作;

看 SQLKeyStore 中一些复合操作(如 putIfAbsent)返回值为什么要加上更新的数量?先看以下四种可能情况:

  1. 数据库中不存在该 idpKey,则插入数据,数据库中 idpKey 状态为 EXECUTING,返回数据状态为 EXECUTING,此时程序应该继续执行;
  2. 数据库中存在该 idpKey,且状态属于 pass,则更新成功,数据库中 idpKey 状态为 EXECUTING,返回数据状态为 EXECUTING,此时程序应该继续执行;
  3. 数据库中存在该 idpKey,且状态属于 blocking,则更新失败,返回数据状态为 EXECUTING,此时程序应该被阻塞
  4. 数据库中存在该 idpKey,且状态属于 reject,则更新失败,返回数据状态属于 reject 集合,则该次调用应该被放弃。

其中,情况 2 和 3 可以使用成复杂状态机系统设计功更新数量来区分,因此在返回值中加上了这个更新数量。

隔离性及可能导致的安全问题

基于数据库的幂等性检查需要查询数据库,第一反应是可以并入业务本身的事务、利用现成的事务管理器来执行数据库操作,如果是 Spring,可以通过传入一个 TransactionManager 实现,代码量不会很大。但是最终没有采用,下面讨论这么做的动机。

其实也实现了一个 TransactionManager 的版本,叫 TxKeyStore。

一方面的问题来自事务的原子性:事务要么执行完,要么失败回滚,但是这种回滚会将接口调用状态的扭转变得更加复杂,难于管理。
还有一方面的问题来自隔离性,隔离性是在设计数据库时必然会提到的话题,从最简单的读未提交到最严格的串行化,隔离性描述了事务之间互相影响的程度,其中,读未提交没有作任何限制,读已提交和可重复读这两个等级一般采用 MVCC 机制来实现,MySQL 的默认级别就是读提交,而串行化的方式一般是采用加锁来实现的,事务只能一个一个执行,粒度较大,效率太低一般不采用。所以现在的问题是:为什么可重复读不满足我们的需求?
我们知道可重复读是通过 MVCC 实现的,简而言之,在进行操作的时候会利用undo log来拿到数据在访问前的状态,这样可以在不使用锁的前提下保证并发安全性。但是,为了效率起见,MVCC 的实现有时候并不排除幻读和丢失更新现象:

  1. 一个事务中有多次查询,在后面的查询中能查到之前不存在的数据,这就是常提到的幻读现象。
    幻读有时候会被误认为能用 MVCC 来解决,看下面的事务执行图:
    事务MVCC不能避免幻读
    事务 2 是在事务 1 之前执行的,这意味着事务 2 插入的数据能被事务 1 看到,事务 1 第一次读数据的时候,数据尚不存在,之后事务 2 插入了数据,事务 1 再读就能读出来了。
    解决幻读的方法主要是 Next-Key Lock,包括处理某个确定值的行锁和处理某个区间的 gap 锁。
  2. 脏读、不可重复读、幻读三个问题考虑的场景基本上都是一个事务写一个事务只读,但没有考虑两个事务都在对数据进行修改的情况。如下图所示:
    事务MVCC不能避免丢失更新问题
    两个事务都读取数据 A 然后对其执行写操作,但因为事务 2 是后写的,所以事务 A 作的修改就被覆盖掉了,这就是丢失更新问题。
    丢失更新问题一般通过加锁来解决,更具体地说,是行锁中的排他锁(共享锁就不行了,而且共享锁很容易导致死锁),在读出数据的时候使用 “for update” 子句来加锁,使两个事务不能同时对该条数据进行读写操作。
    在代码中,幂等检查是更加复合的操作,加行锁可以保证一次请求的多次重试之间互斥,但又不必像设置成串行化隔离级别会将所有请求的事务都调整成互斥的,这样粒度太大、影响并发性能。

总而言之,代码中的 JdbcKeyStore 没有采取并入业务操作所在事务的方案,而是使用了数据库行锁来保证并发安全性,行锁可以保证操作同一条数据的两个事务能够按先后顺序进入临界区,这同时解决了幻读问题。至于为什么不用乐观锁,这在上边已经提到过了。

五、测试

幂等检查器的原理是给所有非幂等的接口织入检查代码(AOP),这样的接口是比较多的,因此性能的好坏可以直接影响业务逻辑的执行效率。因此:

  • 一方面需要保证幂等检查本身是正确无误的;
  • 另一方面需要评估它对业务本身造成的影响、是否在可接受的性能损耗范围内。

功能测试(并发)

检查组件是否正常工作的手段主要是将同一请求发多次到一个接口,观察成功执行的情况下是否只执行了一次,失败的情况下是否有重试。根据执行状态有下面几个测试点:

  1. 同时发 3 个请求,1 次正常执行,其他被抛弃;
  2. 同时发 3 个请求,1 次超时,其他被抛弃;
  3. 同时发 3 个请求,1 次抛出 Exception 异常,重试一次成功,其他被抛弃;
  4. 同时发 3 个请求,1 次抛出 RuntimeException(或 Error)异常,其他被抛弃。

第 2 条不管超时后是否有执行成功都会抛弃掉其他重试请求,因为特别长时间的超时影响用户体验,本身就没有继续执行下去的必要。
第 3、4 条区别是异常的类型——也就是必检和免检的区别,免检异常一般是代码有漏洞或无法恢复的系统崩溃这些情况,重试几次一般也不会有什么作用,所以不如直接放弃好了。

具体测试用例见 项目下的 tidp-test 模块

参考

Spring Cloud Sleuth
zipkin
OpenTracing

  1. 怎样正确做 Web 应用的压力测试?
  2. 【测试设计】性能测试工具选择:wrk?jmeter?locust?还是 LR?