接口幂等性

目录

幂等性介绍

代码实现接口幂等性


幂等性介绍

幂等性概念

幂等性是一个数学概念,f(f(x))=f(x),对一个函数多次作用后和第一次结果相同。引述到实际项目中,接口的幂等性就是无论此接口运行几次,运行结果都和运行一次结果一致。

使用接口幂等性场景

**不是所有的接口都需要幂等性,要根据业务场景而定,**大部分业务场景服务接口是不需要幂等性的。常用的场景如下:

1、重复提交的场景,如购物网站快速多次点击提交订单,防止生成多个订单

2、接口重试,在微服务架构下,服务接口重试,防止服务调用重复

各类操作接口幂等性

1、select 接口:查询接口天然支持幂等性,不需要额外增加控制幂等性的业务逻辑

2、delete接口:删除接口,删除一次资源和删除多次资源结果相同,满足幂等性

3、update接口:分情况而定,如存在累加更新次数场景就不满足幂等性,增加数据版本号,通过乐观锁实现幂等性

4、insert接口:可能重复创建资源,不满足幂等性。通过token+分布式锁保证接口幂等性

5、复杂混合操作接口:包含了各种insert、update、delete等操作的接口,操作逻辑参考4,使用token+分布式锁保证接口幂等性

代码实现接口幂等性

项目背景如下:

项目中有订单表t_order,结构说明及初始数据如下:

1、修改接口实现幂等性(增加数据版本号,通过乐观锁实现幂等性)

乐观锁是数据库并发控制中的一种机制,通过数据版本记录实现事务处理的非阻塞性访问。其核心原理是为数据表增加"version"字段记录版本号,读取数据时同步获取版本信息,更新时将提交版本号与数据库当前版本号比对,仅当两者一致时才执行更新操作。

修改接口如下:

java 复制代码
package com.gingko.interfaceidempotence.service.impl;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.gingko.interfaceidempotence.entity.TOrder;
import com.gingko.interfaceidempotence.mapper.TOrderMapper;
import com.gingko.interfaceidempotence.service.TOrderService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;

@Service
@Transactional
public class TOrderServiceImpl implements TOrderService {

    @Resource
    private TOrderMapper tOrderMapper;
    @Override
    public void update(TOrder tOrder) {
        int orderUpdateCountOld = tOrder.getOrderUpdateCount();
        tOrder.setOrderUpdateCount(orderUpdateCountOld + 1);//设置更新次数
        int orderVersionOld = tOrder.getOrderVersion();
        tOrder.setOrderVersion(orderVersionOld + 1);//设置更新版本
        //update t_order set order_update_count = order_update_count + 1 ,order_version = order_version + 1
        // where order_id = ? and order_version = ?
        UpdateWrapper<TOrder> updateWrapper = Wrappers.update();
        updateWrapper.eq("order_id",tOrder.getOrderId());
        updateWrapper.eq("order_version",orderVersionOld);
        tOrderMapper.update(tOrder, updateWrapper);
    }
}

通过jmeter模拟5个线程快速重复提交请求,通过控制台sql发现,只有第一个线程请求更新了数据库记录,其他4个线程没有数据库更新,最终更新次数order_update_count 是1,数据版本是1,接口满足幂等性。

2、新增或复合接口实现幂等性(token+分布式锁保证接口幂等性)

场景说明:创建好订单后,购物网站点击【支付】,项目模拟生成支付记录,支付记录表如下:

没做幂等性接口如下:

java 复制代码
package com.gingko.interfaceidempotence.controller;
import com.gingko.interfaceidempotence.entity.TPayRecord;
import com.gingko.interfaceidempotence.service.TPayRecordService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.UUID;

@RestController
@RequestMapping("payRecord")
public class PayRecordController {

    @Resource
    private TPayRecordService tPayRecordService;

    @PostMapping("/genPayRecord")
    public String genPayRecord(@RequestBody TPayRecord tPayRecord) {
        String payRecordId = UUID.randomUUID().toString();
        tPayRecord.setPayRecordId(payRecordId);
        this.tPayRecordService.genPayRecord(tPayRecord);
        return "success";
    }
}

通过Jemter 5个线程模拟快速重复点击页面【支付】, 一个订单数据库生成了5笔支付记录,相当于支付了5次,显然不符合幂等性要求。

接口为了达到幂等性要求,实现的思想是:

1、后台生成一个业务唯一标识token(在分布式环境下,建议放在redis中保存)

2、前台请求后台支付接口时带上token

3、后台生成支付记录接口(即需要满足业务接口幂等性要求的接口)内部校验token的准确性和时效性,不满足直接抛出异常,认为是前台伪造的token

4、当前台请求带入的token满足准确性和时效性,通过分布式锁(本文基于Redisson实现分布式锁)控制重复提交,即控制只生成1条支付记录

代码修改如下:

1、项目增加redis和redission的支持

XML 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.gingko</groupId>
    <artifactId>interface-idempotence</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>interface-idempotence</name>
    <description>Demo project for Spring Boot</description>
    <properties>
        <java.version>1.8</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <spring-boot.version>2.7.6</spring-boot.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.2</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.16</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <!-- redisson -->
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.14.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                    <encoding>UTF-8</encoding>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>${spring-boot.version}</version>
                <configuration>
                    <mainClass>com.gingko.interfaceidempotence.InterfaceIdempotenceApplication</mainClass>
                    <skip>true</skip>
                </configuration>
                <executions>
                    <execution>
                        <id>repackage</id>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>

2、配置增加redis的支持

XML 复制代码
server:
  port: 8080 #配置应用端口
spring:
  datasource:
    type: com.zaxxer.hikari.HikariDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost/ds0?useSSL=false&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&serverTimezone=Asia/Shanghai&allowMultiQueries=true
    username: root
    password: 123456
    hikari:
      connection-timeout: 30000       # 等待连接池分配连接的最大时长(毫秒),超过这个时长还没可用的连接则发生SQLException, 默认:30秒
      minimum-idle: 10                 # 最小连接数
      maximum-pool-size: 50           # 最大连接数
      auto-commit: true               # 自动提交
      idle-timeout: 600000            # 连接超时的最大时长(毫秒),超时则被释放(retired),默认:10分钟
      pool-name: DateSourceHikariCP   # 连接池名字
      max-lifetime: 1800000           # 连接的生命时长(毫秒),超时而且没被使用则被释放(retired),默认:30分钟 1800000ms
      connection-test-query: SELECT 1
  redis: #redis config
    host: 127.0.0.1
    database: 0
    port: 6379
# MyBatis plus 相关的配置
mybatis-plus:
  mapper-locations: classpath:mappers/*.xml
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开启mybatis的日志实现,可以在控制台打印输入sql语句,debug使用,生产时需要关掉
    map-underscore-to-camel-case: true #配置文件需要开启驼峰命名映射,只有这样,才能映射到字段,从而创建出不为空的对象
  type-aliases-package: com.gingko.interfaceidempotence.entity       # 所有数据库表逆向后所一一映射的实体类
# 通用mapper配置
mapper:
  mappers: com.baomidou.mybatisplus.core.mapper.BaseMapper      # 所有Mapper都需要实现的接口
  not-empty: false    # 在进行数据库操作的时候,判断一个属性是否为空的时候,是否需要自动追加部位空字符串的判断
  identity: MYSQL
#日志配置
logging:
  level:
    root: info
  file:
    name: /springboot.log
  pattern:
    console: "%d{yyyy/MM/dd-HH:mm:ss} [%thread] %-5level %logger{50}- %msg%n"
    file: "%d{yyyy/MM/dd-HH:mm:ss} ---- [%thread] %-5level %logger{50}- %msg%n"

3、 修改支付接口接口,让其支持幂等性

java 复制代码
package com.gingko.interfaceidempotence.controller;
import com.gingko.interfaceidempotence.entity.TPayRecord;
import com.gingko.interfaceidempotence.service.TPayRecordService;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import javax.servlet.http.HttpSession;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@RestController
@RequestMapping("payRecord")
@Slf4j
public class PayRecordController {

    //order token key:项目设计为:order_token + sessionid
    private String orderTokenKey;

    @Resource
    private TPayRecordService tPayRecordService;
    @Resource
    private RedisTemplate redisTemplate;
    @Autowired
    private RedissonClient redissonClient;

    @PostMapping("/genToken")
    public String genToken(HttpSession session) {
        String sessionId = session.getId();
        log.info("sessionId:{}",sessionId);
        this.orderTokenKey = "order_token_" + sessionId;
        String token = UUID.randomUUID().toString();
        //放入redis中,10分钟后过期
        redisTemplate.opsForValue().set(this.orderTokenKey,token,
                600, TimeUnit.SECONDS);
        return token;
    }

    @PostMapping("/genPayRecord")
    public String genPayRecord(@RequestBody TPayRecord tPayRecord) {
        //加入分布式锁
        //唯一业务标识校验
        String token = tPayRecord.getToken();//前台传递的token
        RLock lock = redissonClient.getLock(token);
        lock.lock(5,TimeUnit.SECONDS);//上锁5秒,根据业务情况定
        //业务逻辑
        try {
            String tokenFromRedis = (String) redisTemplate.opsForValue().get(this.orderTokenKey);
            if(tokenFromRedis == null) {
                throw new RuntimeException("token为空");
            }
            if(!tokenFromRedis.equals(token)) {
                throw new RuntimeException("token不正确");
            }
            log.info("token正确...");
            /**
             * 第一次执行完成删除redis中的token,其他并发请求再次进来后由于没有token抛出异常,
             * 进而实现防止重复生成多笔支付记录,满足了接口幂等性
             * 由于orderTokenKey包含了sessionid信息,所以能保证不影响其他用户生成支付记录
             */
            redisTemplate.delete(this.orderTokenKey);
        }finally {
            lock.unlock();//释放锁
        }
        //生成支付记录逻辑
        String payRecordId = UUID.randomUUID().toString();
        tPayRecord.setPayRecordId(payRecordId);
        this.tPayRecordService.genPayRecord(tPayRecord);
        return "success";
    }
}

4、Jemter模拟5个线程并发请求生成支付记录,最终结果如下:

最终只生成了1条支付记录,其他请求获得锁时,由于没有token导致支付记录不会重复生成,符合预期。

相关推荐
沉着的码农11 天前
【分布式】Redisson滑动窗口限流器原理
java·redis·分布式·redisson
llwszx13 天前
分布式锁的四种实现方式:从原理到实践
java·分布式·spring·分布式锁
麓殇⊙19 天前
redisson锁的可重入、可重试、超时续约原理详解
redis·分布式锁
jstart千语19 天前
【Redisson】锁可重入原理
redis·分布式·redisson
啾啾Fun1 个月前
【Java微服务组件】分布式协调P4-一文打通Redisson:从API实战到分布式锁核心源码剖析
java·redis·分布式·微服务·lua·redisson
xujinwei_gingko1 个月前
分布式锁-Redisson实现
分布式锁
小马爱打代码1 个月前
Redisson - 实现延迟队列
redisson
快乐肚皮1 个月前
Redisson学习专栏(四):实战应用(分布式会话管理,延迟队列)
分布式·学习·redisson·延迟队列·分布式会话
JAdroid1 个月前
spring-boot redis lua脚本实现滑动窗口限流
数据库·spring boot·redis·spring·lua·redisson