前言
今天想聊一聊幂等相关的知识,以及实现一个幂等公共组件需要重点涉及和思考的点。
概念
首先,什么是幂等,在实际代码生产过程中有什么作用呢?
在编程中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。
举个例子,假如有个方法,用于修改一个订单的状态为已完成,只改一个状态字段,要达到幂等的效果我们可以这样:
- 每次执行都正真执行更新语句接口,结果都是状态保持已完成
- 每次执行先判断订单状态是否已经是已更新状态,如果是就返回,如果不是就执行更新语句,也过也是保持已完成状态
所以,一个拥有幂等性的业务代码,就可以保证外部重复调用的结果和单次调用的结果一致,保证这一点在实际代码生产中的一些场景中是非常重要的。以下做一些例举:
- 客户端并发重复提交,这种比较常见,用户连续点击按钮即可触发重复提交
- 微服务架构中Http或RPC请求调用失败触发重试
- 消息中间件重复消费,消息中间件本身就是通过重复消费达到业务解耦和一致性的,所以使用消息是必然需要考虑幂等情况的
- 调用方定时任务重复调用或者上游触发历史业务请求,从不信任外部的角度看,有时也需要考虑
总结一下:
At least once + 幂等 = exactly once
逻辑
一下是使用较多的幂等方案的流程图如下:
- 一个微服务系统中在进入业务执行前必须要保证拿到分布式锁,这样才能屏蔽掉并发重复请求
- 执行业务和幂等标记的存储需要保证原子性,才能保证不会出现幂等标记和业务变更的数据不一致情况的发生,否则这个幂等标记就没有意义了
公共组件
公共组件的例子以Spring为基础,其中会使用到Spring相关的组件。
设计
根据前面的流程图,使用AOP非常适合实现一个公共组件。
代码
定义注解提供给业务使用:
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
/**
* Name used to determine the target idempotent key prefix
*/
String name();
/**
* support Spring Expression Language (SpEL)
*/
String key();
/**
* set idempotent key expire time, default 0 second
*/
long idempotentExpire() default 0;
/**
* set lock expire time default 60 seconds
*/
long lockExpire() default 60;
}
- 注解中的信息包含幂等key,锁过期时间,幂等保护时间,注意这里的key支持SpEL,这样就可以非常方便的可以把方法中的参数作为key的一部分
通过注解切面,核心代码如下:
public <T> T execute(IdempotentRequest request, Supplier<T> processSupplier, Supplier<T> failSupplier) {
String idempotentKey = request.getKey();
long idempotentExpire = request.getIdempotentExpire();
long lockExpire = request.getLockExpire() == 0 ? DEFAULT_LOCK_TIME : request.getLockExpire();
IdempotentRecordStorage idempotentRecordStorage = getIdempotentRecordStorage(idempotentExpire);
try {
boolean locked = redisLockService.lock(idempotentKey, LOCK_VALUE, lockExpire, true);
if (locked) {
if (idempotentRecordStorage.hasKey(idempotentKey)) {
return failSupplier.get();
}
T result = processSupplier.get();
idempotentRecordStorage.setKey(idempotentKey, idempotentExpire);
return result;
} else {
return failSupplier.get();
}
} finally {
redisLockService.unlock(idempotentKey, LOCK_VALUE, true);
}
}
看一下Oracle 的实现例子:
public class OracleStorage implements IdempotentRecordStorage {
private JdbcTemplate jdbcTemplate;
public OracleStorage(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public void setKey(String key, long expire) {
Date expireDate = expire == 0 ? null : new Date(System.currentTimeMillis() + expire * 1000);
String sql = "insert into AAP_IDEMPOTENT_RECORD(ID, KEY, CREATE_TIME, EXPIRE_TIME) values(IDEMPOTENT_RECORD_SEQUENCE.nextval, ?,?,?)";
jdbcTemplate.update(sql, key, new Date(), expireDate);
}
@Override
public boolean hasKey(String key) {
String sql = "select count(1) from AAP_IDEMPOTENT_RECORD WHERE KEY = ?";
Integer value = jdbcTemplate.queryForObject(sql, Integer.class, key);
return value > 0;
}
@Override
public StorageTypeEnum getType() {
return StorageTypeEnum.ORACLE;
}
}
思考
在实际coding的过程中有几个有意思的点:
- 1,想把幂等记录操作和业务操作放入一个事务内,才能保证前面图中的原子操作,而一般我们会使用@Transaction注解,这个注解也和我们一样使用AOP实现,所以问题就来了,两个切面的顺序性需要做准确的调整,因为我的例子项目里没有设置@Transaction切面的
order
,所以默认是Integer.MAX_VALUE
,自定义的切面也默认是Integer.MAX_VALUE
,所以就出现了@Transaction注解在内层,导致变成两个事务的提交,而不能保证原子性。调整顺序方式:@EnableTransactionManagement(order = Ordered.LOWEST_PRECEDENCE - 100)
- 2,当我以为业务操作和幂等操作在一个事务的时候我产生了一个疑惑,幂等操作自己提前会先提交吗?如果会的话,那又保证不了原子了。这里注意使用的是
jdbcTemplate
,底层还是会和@Transaction注解一样拿到相同的Connection,所以可以达到一起提交的能力。 - 3,如果使用Redis做幂等数据的操作,那么就需要额外考虑保证原子性的方法,比如在setKey的位置实际执行成功,但是返回网络问题抛出异常,前面业务操作的事会被回滚,但是幂等数据实际已经存在的问题。为了解决这个问题,更倾向于提供给使用方决定何种情况下需要清楚幂等数据。这里代码没有提供,需要补充。
以上是个人的一些思考,实现代码放在github,欢迎交流:
https://github.com/dchack/crab