1.概念
幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。
在编程中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。例如,“setTrue()”函数就是一个幂等函数,无论多次执行,其结果都是一样的.更复杂的操作幂等保证是利用唯一交易号(流水号)实现. ——————【维基百科】
简单来说就是 一个操作如果执行多次,其产生的影响均与一次执行的影响相同。
2.为什么要做幂等
分布式场景:
- 一个订单创建接口,在第一次调用时超时。
- 订单创建时,要先减去库存,调用接口时发生了超时。
- 订单开始支付,支付请求发出了之后,在到达微信支付前就发生超时。
- 到达微信支付,但是支付交易失败,未返回,此时超时。
- 到达微信支付且交易成功,未返回回执,此时超时。
- 到达微信支付并且交易成功,返回了回执,客户端未接收到回执此时客户端超时。
- 订单支付成功,调用订单状态更新接口,调用方发送了两条消息,一个是已创建,一个是已付款,如果先收到已付款消息后收到已创建。
幂等性在购物支付场景下显得尤为重要,我请求了多次是否会产生了多笔订单,是否引发了多次支付?
为了解决这些问题,就必须保证API的幂等性,也就是接口可重复调用,接口最终结果是与一次调用时一致。(这里说的是数据一致性,不是返回结果一致性,如:已提交订单,再次提交提示请勿重复提交,但是数据结果与第一次调用时一致)
3.幂等如何实现
具体方案有:业务去重表(建立唯一索引)、悲观锁、乐观锁(版本控制、有限状态机验证)、Token(全局唯一标识)、分布式锁、select+insert、
3.1 Token(全局唯一标识)
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zWvsvbX4-1574164156618)(https://i.loli.net/2019/11/04/srAzcduVxBUCnZR.png)]
- 在数据提交前向服务申请token,token放到redis或者内存中(加入失效时间);在提交时后台校验token,同时删除token,生成新的token返回。(此处校验token和删除token为原子性操作,,如果分为两步,存在并发问题。)
- 全局唯一标识原理与之类似,根据操作类型和业务标识组合生成一个全局ID,执行操作前判断是否已存在该key,如果不存在则存储到系统中,存在则表示该方法已操作。(利用Redis的setnx命令。此命令同样是原子性操作,只有在key不存在的情况下,才能set成功。)
3.2 唯一索引
在CRUD中查询和删除(物理删除、逻辑删除)是天然的幂等的,既多次查询对数据与一次查询一致不会产生影响,删除也是无论是物理删除还是逻辑删除,都是根据条件进行删除无论执行几次造成的结果是一样的。
增加数据时如果不进行幂等会导致目标数据产生多条,此时建议将目标表增加唯一索引或者唯一组合索引防止脏数据,在重复进行执行时,插入失败时,重新查询数据此时已存在则成功返回。
3.3 数据库悲观锁
获取数据时加锁
select * from table_xxx where id='xxx' for update;
使用时ID需要是主键或者唯一索引,在mysql中会导致锁表而不是行级锁。悲观锁使用一般同事务一起使用。
3.4 数据库乐观锁
乐观锁在更新数据时才会产生锁表,其他时间不锁表,所以对比悲观锁,乐观锁并发场景使用效率更高。
实现方式:
原理如图:同java CAS
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wKpbeU5H-1574164156620)(https://i.loli.net/2019/11/03/rLB2RXT4YS13Msq.png)]
- 通过版本号或者状态条件实现
update table_xxx set name=#name#,version=version+1 where version=#version# and id=#id#;
- 通过条件限制
update table_xxx set avai_amount=avai_amount-#subAmount# where avai_amount-#subAmount# >= 0 and id=#id#;
此场景适用于秒杀等库存场景,防止因并发出现库存-1场景。
3. 任务相关的业务,肯定会涉及到状态机(状态变更),业务数据在不同情况下回发生变更,一般情况下存在有限状态机,如果状态机位于下一个状态,此时来了一个上一个状态的变更,此时不能变更,从而保证有限状态机的幂等。
例:订单创建为0,付款成功为2,付款失败为1在进行状态机更新时可以这样控制
update table_xx set status=#status# where id=#id# and status<#status#
如果此次操作成功该update返回结果为1条,若数据已被更新则返回结果为0条,可再次查询验证更新状态是否成功。
3.5 select + insert
对并发不高的系统,为了支持幂等可以先查询下数据判断是否已经执行过,未执行则进行业务的处理。注:此种方法高并发场景切不可使用
3.6 业务去重表
上面几种通过数据库进行幂等的方法有一个弊端,就是为了实现幂等性需要在业务表中加入一个无关的版本号或者状态,此时可将操作单独提取出来构建一个业务去重表,在业务表操作前对此表进行操作验证,利用数据库唯一索引特性保证唯一逻辑,唯一的序列号可以是一个字段,也可以是多字段的唯一性组合,在幂等验证通过后方可进行业务操作。
表结构:
create table DUPLICATEREMOVAL(
c_id VARCHAR2(32) not null,
c_serial_no VARCHAR2(255) not null,
c_source_type VARCHAR2(255),
c_status VARCHAR2(64),
c_remark VARCHAR2(255),
c_crt_cde VARCHAR2(20) ,
t_crt_tm DATE ,
c_upd_cde VARCHAR2(20),
t_upd_tm DATE
);
-- Add comments to the table
comment on table DUPLICATEREMOVAL
is '业务去重表';
-- Add comments to the columns
comment on column DUPLICATEREMOVAL.c_id
is '业务标识';
comment on column DUPLICATEREMOVAL.c_serial_no
is '唯一标识';
comment on column DUPLICATEREMOVAL.c_source_type
is '资源类型';
comment on column DUPLICATEREMOVAL.c_status
is '状态';
comment on column DUPLICATEREMOVAL.c_remark
is '备注';
comment on column DUPLICATEREMOVAL.c_crt_cde
is '创建人';
comment on column DUPLICATEREMOVAL.t_crt_tm
is '创建时间';
comment on column DUPLICATEREMOVAL.c_upd_cde
is '修改人';
comment on column DUPLICATEREMOVAL.t_upd_tm
is '修改时间';
-- Create/Recreate primary, unique and foreign key constraints
alter table DUPLICATEREMOVAL
add constraint C_PK_SERIAL unique (C_ID, C_SERIAL_NO);
3.7 分布式锁
在分布式应用的时代背景下,跨系统的调用想满足锁的语义只能依赖于第三方的中间件,比如redis、zookeeper。
实现点:某个流程要求不能并发执行,可以在执行之前根据业务上的唯一标识 获取分布式锁,其他流程执行时获取锁就会失败,也就是同一时间该流程只能有一个能执行成功,执行完后,释放分布式锁。
使用方式:
API文档
加锁:set key value px milliseconds nx
键不存在时才进行操作并设置键的过期时间,此命令是原子操作不会出现多线程问题。
释放锁:在释放锁时为了防止将别的线程加的锁误删可以在加锁时将线程id作为value,解锁时判断是否是当前锁,然后删除。但是判断和释放锁是两个独立的操作不具备原子性所以此处推荐使用del+lua脚本实现。
https://ftp.bmp.ovh/imgs/2019/11/a79904d25d081716.png
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redisClient.eval(luaScript , Collections.singletonList(key), Collections.singletonList(threadId));
分布式锁参考:使用 px nx 加锁
扩展:redis官方支持的分布式锁redlock
4.基于redis实现分布式锁
4.1 核心加锁代码:
//SET IF NOT EXIST key不存在时,set
private static final String SET_IF_NOT_EXIST = "NX";
//expx 超时时间 单位毫秒 EX 单位 秒
private static final String SET_WITH_EXPIRE_TIME = "PX";
public boolean tryGetDistributedLock( String lockKey, String requestId , int expireTime) {
Jedis jedis = null;
try {
jedis = getJedis();
String result = jedis.set(lockKey, requestId , SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
} catch (Exception e) {
log.error("set key:{} value:{} error", lockKey, requestId , e);
} finally {
close(jedis);
}
return false;
}
使用NX PX 来保证插入值时的原子性
NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
PX,意思是我们要给这个key加一个过期设置。
错误使用:
public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {
Long result = jedis.setnx(lockKey, requestId);
if (result == 1) {
// 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
jedis.expire(lockKey, expireTime);
}
}
上面的代码是将赋值的操作分为两步,先设置值,成功了再设置超时时间,但是如果在未设置超时时间前程序崩溃,此处会形成死锁。
4.2 解锁代码:
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
使用lua脚本将解锁时判断如果获取的key = 目标值则执行后续的操作,保证比较并操作行为的原子性。
错误代码:
public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {
// 判断加锁与解锁是不是同一个客户端
if (requestId.equals(jedis.get(lockKey))) {
// 若在此时,这把锁突然不是这个客户端的,则会误解锁
jedis.del(lockKey);
}
}
如果经过上面比较判断后,此时锁因为超时已经过期了,在执行后面的del命令时可能会删除别的进程(线程)加的锁。
代码地址:https://github.com/cmevolve/InterfaceIdempotent
参考资料:https://wudashan.cn/2017/10/23/Redis-Distributed-Lock-Implement/
5.代码测试(工具扩展)
压力测试工具:JMeter
性能分析工具:JProfiler 基本使用手册
Springboot+redis+注解+拦截器