一、接口幂等性
1.1 接口幂等性定义
在HTTP/1.1中,对幂等性进行了定义。它描述了一次和多次请求某一个资源对于资源本身应该具有同样的结果(网络超时等问题除外),即仅第一次请求会对资源产生副作用,后续的多个请求对于同一个资源不会产生副作用。
这里的副作用是不会对结果产生破坏或者产生不可预料的结果。也就是说,其任意多次执行对资源本身所产生的影响均与第一次执行的影响相同。
1.2 实现接口幂等性的优劣
以下情况若不保证接口幂等性,将会对系统产生不可预知的问题。
- 前端重复提交表单:用户提交表单时,很多时候会因网络抖动没有及时对用户做出提交成功响应,致使用户认为没有成功提交,然后一直点提交按钮,这时就会发生重复提交表单的请求。这种情况在电商系统中实现扣款逻辑时尤为重要。
- 用户恶意进行刷单:例如投票功能,若用户针对一个用户进行重复提交投票,这样会导致接口接收到用户重复提交的投票信息,这样会使投票结果与事实严重不符。
- 接口超时重复提交:很多时候 HTTP 客户端工具都默认开启超时重试的机制,尤其是第三方调用接口时,为了防止网络抖动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次。
- 消息重复消费:当使用 MQ 消息中间件时,若发生消息中间件出现错误未及时提交消费信息,会导致发生重复消费。
因此,上述情况需保持接口的幂等性,保证系统的健壮性。但是,引入幂等性会增加了服务端的逻辑复杂性和成本,其主要是:
- 把并行执行的功能改为串行执行,降低了执行效率。
- 增加了额外的控制幂等的业务逻辑,复杂化了业务功能。
所以,需针对具体的业务场景,考虑是否有引入幂等性的必要,除了业务上的特殊要求外,一般情况下不需要引入的接口幂等性。
二、Restful API 接口的幂等性
Restful 推荐的几种 HTTP 接口方法中,分别存在幂等性与不能保证幂等性的方法,如下所示:
三、解决方案
3.1 唯一性约束
3.1.1 数据库唯一主键
该方案是在数据库层面防止重复,利用的是数据库的主键唯一约束的特性,解决了 insert 数据时的幂等性,比如以订单号为唯一约束,订单号相同的订单不可能存在两条记录的插入。此外,也适用于以主键作为唯一查询条件的删除操作。
此外,数据库唯一主键不能是使用数据库中的自增主键,而是使用分布式 ID 充当主键,这样才能保证在分布式环境下 ID 的全局唯一性。且若是在分库分表的情况下,路由规则要保证相同请求下,落地在同一个数据库和同一张表中,因为,不同的数据库和表主键是不相关的。
3.1.2 redis set 防重
数据量较大,且只能被处理一次时,可对数据做 MD5 处理,并将其放入 redis 的 set 中。因此,在每次处理数据前,先查看数据对应的 MD5 是否已经存在,若存在,则不处理。
3.2 锁机制
3.2.1 数据库悲观锁
使用 for update
来实现数据库的悲观锁,但是需要注意的是,在使用数据库悲观锁时,需要关闭数据库的自动提交事务功能 autocommit
。
此外,因为悲观锁使用时一般是伴随着事务一起使用的,数据锁定的时间可能会很长,需要根据实际情况选用。且 id
字段得是主键或唯一索引,否则会造成数据库不使用行锁,而使用表锁的情况,这会造成很大的性能损失,处理起来会较麻烦。
SELECT * FROM [TABLE] WHERE id = #{id} FOR UPDATE
3.2.2 数据库乐观锁
数据库乐观锁通过在表结构中增加一个 version
字段或 update_time
字段实现,适用于更新的场景中,且是读多写少的场景。语句如下所示:
UPDATE [TABLE] SET count = #{count} - 1, version = #{version} + 1 WHERE id = #{id} ADN version = #{version}
根据 version
字段,即在操作数据库前,先获取当前记录的 version
值,然后在操作的时候携带该 version
值。具体的示例如下:
- 第一次操作数据库时,得到的 version = 1,调用库存服务时,version 更新成了 2;
- 此时,返回给订单服务出现了问题,订单服务又一次的调用库存服务,订单服务还是会携带 version = 1 去调用库存服务;
- 此时再次执行 sql 语句时,就不会执行成功。因为 version 已经改变了。
3.3 token 机制
针对客户端连续点击或者调用方的超时重试等情况,例如提交订单,此种操作就可以用 Token 的机制实现防止重复提交。
该方案的具体过程如下:
- 调用方在调用接口的时候先向后端请求一个全局 ID(Token);
- 调用方请求的时候携带这个全局 ID 一起请求(Token 最好将其放到 Headers 中);
- 此时,后端需要对这个 Token 作为 Key,用户信息作为 Value 到 Redis 中进行键值内容校验,用户数据量过大时,可对请求数据做 MD5 存储。
- 若 Key 存在且 Value 匹配就执行删除命令,然后正常执行后面的业务逻辑。
- 若不存在对应的 Key 或 Value 不匹配就返回重复执行的错误信息。
此外,需要注意,在并发情况下,执行 Redis 查找数据与删除需要保证原子性,否则很可能在并发下无法保证幂等性。其实现方法可以使用分布式锁或者使用 Lua 表达式来注销查询与删除操作。
3.4 全局请求唯一 ID
调用请求接口时,生成一个唯一 id
,redis 将数据保存到 set 中,存在即表示已处理过。可使用 nginx 设置每一个请求的唯一 id
。
proxy_set_header X-Request-Id $request_id;
四、基于 token 令牌实现示例
示例使用 maven 作为项目依赖管理,项目结构如下所示:
pom.xml 依赖文件如下:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- 解决 spring-data-redis 底层使用 lettuce 产生的堆外内存溢出 -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
yml 文件配置如下:
server:
port: 9999
spring:
redis:
host: 192.168.125.140
port: 6379
timeout: 1000
database: 0
jedis:
pool:
max-active: 100
max-wait: -1
min-idle: 0
max-idle: 20
生成和验证 Token 的 TokenService
工具如下:
@Service
public class TokenService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:";
public String generateToken(String value) {
// 当传入的 value 是较大的数据量或者 json 时,可对其进行 MD5 处理,再存储到 redis 中
String token = UUID.randomUUID().toString();
String key = IDEMPOTENT_TOKEN_PREFIX + token;
redisTemplate.opsForValue().set(key, value, 3, TimeUnit.MINUTES);
return token;
}
public boolean validToken(String token, String value) {
// 使用 LUA 脚本保证查找和删除的原子性
String script = "if redis.call('get', KEYS[1]) == ARGV[1] " +
"then return redis.call('del', KEYS[1]) " +
"else return 0 end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
String key = IDEMPOTENT_TOKEN_PREFIX + token;
Long result = redisTemplate.execute(redisScript, Arrays.asList(key), value);
if (Objects.nonNull(result) && result > 0) {
System.out.println(String.format("验证 token=%s,key=%s,value=%s 成功", token, key, value));
return true;
}
System.out.println(String.format("验证 token=%s,key=%s,value=%s 失败", token, key, value));
return false;
}
}
数据实体类 OrderInfo
如下:
@Data
@AllArgsConstructor
@NoArgsConstructor
public class OrderInfo {
private String orderNum;
private String customer;
}
接口请求控制器 TokenController
如下:
@RestController
public class TokenController {
@Autowired
private TokenService tokenService;
@GetMapping("/token/generate")
public ResponseEntity<Object> generateToken(String customer) {
String token = tokenService.generateToken(customer);
return ResponseEntity.ok(token);
}
@PostMapping("/order/pay")
public ResponseEntity<Object> submitPayment(@RequestHeader("token") String token, @RequestBody OrderInfo orderInfo) {
boolean validResult = tokenService.validToken(token, orderInfo.getCustomer());
if (validResult) {
return ResponseEntity.ok("处理成功");
}
return ResponseEntity.ok("该订单为重复订单");
}
}
五、测试结果
开启应用,并使用 Postman 进行测试。首先通过接口 /token/generate
获取 Token,再执行幂等性接口 /order/pay
操作。
1)获取 Token。
2)在 Headrs 中携带 Token。
3)接口幂等性验证。
4)此时再点击一次,结果如下: