API 设计中的幂等性
什么是幂等性
幂等性是指无论一个操作执行多少次,最终的结果都是一样的。也就是说,重复执行同一个操作不会改变系统的状态或产生不同的结果。
想象你在一栋大楼里等电梯。你按下电梯按钮的5楼按钮键,电梯开始向5楼的位置移动。后面即使你再按几次5楼按钮键,电梯也不会因为你多次按按钮而加速或停止。无论你按多少次按钮,电梯都会正常到5楼。这就是体现了冥等性。
什么时候会产生幂等性问题
那么,什么情况下,会产生接口幂等性的问题呢?
- 网络波动, 可能会引起重复请求
- 用户重复操作,用户在操作时候可能会无意触发多次下单交易,甚至没有响应而有意触发多次交易应用
- 使用了失效或超时重试机制(Nginx重试、RPC重试或业务层重试等)
- 页面重复刷新
- 使用浏览器后退按钮重复之前的操作,导致重复提交表单
- 使用浏览器历史记录重复提交表单
- 浏览器重复的HTTP请求
- 定时任务重复执行
- 用户双击提交按钮
一句话:只要有有可能重复发起请求的地方都有可能产生接口幂等性问题,无论是用户发起的还是系统内部重试引起的。
这里说的重复发起请求有可能
引发接口幂等性问题,而不是一定,是因为有些操作是天然冥等的,如GET请求,这些请求即使重复再多次操作结果都是一样的,不会出现幂等性问题,就好像前面的电梯按钮一样。
为什么接口需要保证幂等性
很显然,如果一个支付接口没有保证冥等性,用户买了一件商品,然后支付,用户不小心多按了一次或者网络抖动系统重试,导致用户多扣了几次钱,我想用户会拿你祭天吧。
哪些接口需要保证接口的冥等性?
需要保证接口的冥等性的接口:
- 支付接口:重复支付会导致用户账户扣费多次。
- 订单创建接口:重复创建订单会导致多个订单产生。
- 用户注册接口:重复注册会导致多个用户账户。
- 数据修改接口:重复提交会导致数据不一致或重复修改。
不需要保证接口的冥等性的接口:
-
数据查询接口:查询操作本身是无副作用的,不会对系统状态产生影响,如查看商品信息:你可以多次查看某个商品的信息,不会对商品状态产生影响。
-
静态资源获取接口:获取图片、CSS等静态资源,不会修改系统状态。
如何保证接口的冥等性
保证接口的冥等性方法有多种,以下是常见的几种方式:
- 使用唯一请求ID。
- 使用幂等键。
- 乐观锁机制。
- 唯一索引方案。
使用唯一请求ID
每次请求携带一个唯一ID(如UUID),服务器端记录该ID,确保同一个ID的请求只处理一次。
以下是一个使用唯一请求ID来保证接口的冥等性的简单示例:
//使用唯一请求ID防重复攻击
public class UuidDeRepeatedAttackServer {
private Set<String> requestIds = new HashSet<>();
public String processRequest(String requestId, String action) {
if (requestIds.contains(requestId)) {
return "Duplicate request detected";
}
requestIds.add(requestId);
// 执行实际操作
performAction(action);
return "Request processed successfully";
}
private void performAction(String action) {
// 实际操作逻辑
System.out.println("Performing action: " + action);
}
public static void main(String[] args) {
UuidDeRepeatedAttackServer server = new UuidDeRepeatedAttackServer();
String requestId = UUID.randomUUID().toString();
// 第一次请求
String response1 = server.processRequest(requestId, "createOrder");
System.out.println(response1);
// 重复请求
String response2 = server.processRequest(requestId, "createOrder");
System.out.println(response2);
}
}
//输出:
Performing action: createOrder
Request processed successfully
Duplicate request detected
使用幂等键
对于用户敏感操作,使用幂等键(如订单号)来确保同一个操作只执行一次。
想象你在网上购物,每当你提交一份订单时,系统会为你生成一个唯一的订单号。这个订单号就像是幂等键,确保系统在处理订单时能够识别并且只处理一次相同的订单请求。无论你提交订单一次还是多次,系统都会根据订单号来唯一地处理订单,而不会导致重复购买相同商品。
@RestController
@RequestMapping("/api")
public class OrderController {
// 模拟已处理订单的集合
private Set<String> processedOrders = new HashSet<>();
// 模拟订单服务
private OrderService orderService = new OrderService();
// 使用重入锁来确保订单处理的原子性
private Lock lock = new ReentrantLock();
@PostMapping("/placeOrder")
public ResponseEntity<String> placeOrder(@RequestBody OrderRequest orderRequest) {
String orderId = orderRequest.getOrderId();
// 使用订单号作为幂等键,检查订单是否已经处理过
if (processedOrders.contains(orderId)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body("Order already processed. Please do not submit duplicate orders.");
}
// 加锁保证同一时刻只有一个线程可以进入处理逻辑
lock.lock();
try {
// 再次检查订单是否已经处理过(防止并发情况下重复处理)
if (processedOrders.contains(orderId)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body("Order already processed. Please do not submit duplicate orders.");
}
// 处理订单逻辑(这里假设OrderService中有处理订单的业务逻辑)
orderService.processOrder(orderRequest);
// 标记订单已处理
processedOrders.add(orderId);
} finally {
lock.unlock();
}
return ResponseEntity.ok("Order placed successfully. Order ID: " + orderId);
}
// 模拟订单服务类
static class OrderService {
void processOrder(OrderRequest orderRequest) {
// 在实际场景中,这里会有处理订单的具体业务逻辑
System.out.println("Processing order: " + orderRequest.getOrderId());
// 这里可以放置具体的订单处理逻辑
}
}
// 订单请求类
static class OrderRequest {
private String orderId;
public String getOrderId() {
return orderId;
}
public void setOrderId(String orderId) {
this.orderId = orderId;
}
}
// 测试用例
public static void main(String[] args) {
// 模拟多次提交相同订单号的情况
OrderController controller = new OrderController();
OrderRequest request = new OrderRequest();
request.setOrderId("123456");
// 第一次提交订单
ResponseEntity<String> response1 = controller.placeOrder(request);
System.out.println("Response 1: " + response1.getBody());
// 第二次提交相同订单号的订单
ResponseEntity<String> response2 = controller.placeOrder(request);
System.out.println("Response 2: " + response2.getBody());
}
}
//输出
Processing order: 123456
Response 1: Order placed successfully. Order ID: 123456
Response 2: Order already processed. Please do not submit duplicate orders.
乐观锁
乐观锁是一种基于版本控制的并发控制机制,它假设冲突的概率很小,因此允许多个事务同时读取同一数据,但在更新时会进行版本检查,以确保数据的一致性和正确性。如果检测到冲突(即版本不匹配),则会阻止更新操作或者重试更新操作,从而保证操作的幂等性。
让我们通过一个简单的代码示例来说明乐观锁的实现。
class Order {
private String orderId;
private String status;
private AtomicInteger version; // 订单版本号,用于乐观锁控制
public Order(String orderId, String status) {
this.orderId = orderId;
this.status = status;
this.version = new AtomicInteger(0); // 初始版本号为0
}
public String getOrderId() {
return orderId;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public int getVersion() {
return version.get();
}
public void incrementVersion() {
version.incrementAndGet();
}
}
class OrderService {
public boolean updateOrderStatus(Order order, String newStatus) {
// 模拟从数据库加载订单,并获取当前数据库中的订单对象和版本号
Order currentOrderFromDB = getOrderFromDB(order.getOrderId());
int currentVersionFromDB = currentOrderFromDB.getVersion();
// 检查当前版本号是否与数据库中订单对象的版本号一致
if (order.getVersion() != currentVersionFromDB) {
return false; // 版本不一致,更新失败
}
// 执行更新操作
order.setStatus(newStatus);
order.incrementVersion(); // 更新版本号
// 模拟将更新后的订单对象存入数据库
saveOrderToDB(order);
return true; // 更新成功
}
public Order getOrderFromDB(String orderId) {
// 模拟从数据库中获取订单信息
// 在实际应用中,这里会查询数据库获取订单对象
return new Order(orderId, "Pending"); // 这里假设返回一个简单的 Order 对象
}
private void saveOrderToDB(Order order) {
// 模拟将订单对象存入数据库
// 在实际应用中,这里会更新数据库中的订单信息
}
}
public class OccIdempotentExample {
public static void main(String[] args) {
Order order = new Order("123456", "Pending");
OrderService orderService = new OrderService();
// 第一次更新订单状态为 "Processing"
boolean updateResult1 = orderService.updateOrderStatus(order, "Processing");
System.out.println("First update result: " + updateResult1);
// 第二次更新相同订单状态为 "Processing",此时版本号已经增加,应返回更新失败
boolean updateResult2 = orderService.updateOrderStatus(order, "Processing");
System.out.println("Second update result: " + updateResult2);
}
}
//输出:
First update result: true
Second update result: false
在这段代码中,第一次更新 (updateResult1):首次更新订单状态为 “Processing”,版本号从初始值 0 增加到 1,因此更新成功,返回 true。
第二次更新 (updateResult2):再次尝试更新相同订单的状态为 “Processing”,但此时订单对象的版本号已经是 1,与数据库中的版本号不一致,因此更新失败,返回 false。
这样的设计确保了即使多个并发请求同时尝试更新同一订单的状态,只有第一个请求能够成功更新,后续的请求会由于版本冲突而更新失败,从而保证了订单更新操作的幂等性和数据的一致性。
12
唯一索引
唯一索引方案是一种通过在数据库表中使用唯一索引来确保数据的唯一性,从而实现接口的幂等性。
想象你是一家快递公司的工作人员,负责接收和处理包裹的入库操作。每个包裹都有一个唯一的跟踪号(Tracking Number),你需要确保系统中每个跟踪号只能对应一个包裹,避免重复入库同一个包裹。
每当新的包裹到达时,你会在系统中录入包裹信息,包括跟踪号和其他详细信息。系统中的跟踪号字段使用唯一索引,这意味着无论何时你尝试录入一个已经存在的跟踪号,系统会阻止重复录入。
在数据库表中,为跟踪号字段添加唯一索引约束。这样,每个跟踪号都将在数据库层面保证唯一性。当你尝试向数据库插入具有已存在跟踪号的包裹信息时,数据库会返回错误或者插入失败,因为唯一索引要求该字段的值在表中必须是唯一的。这就保证了冥等性。
下面通过一个简单的 Java 示例来模拟包裹入库操作:
// 模拟包裹类
class Package {
private String trackingNumber;
private String recipient;
public Package(String trackingNumber, String recipient) {
this.trackingNumber = trackingNumber;
this.recipient = recipient;
}
public String getTrackingNumber() {
return trackingNumber;
}
public String getRecipient() {
return recipient;
}
}
// 模拟包裹服务类
class PackageService {
private Map<String, Package> packageDatabase = new HashMap<>();
// 模拟包裹入库操作
public boolean receivePackage(Package pkg) {
String trackingNumber = pkg.getTrackingNumber();
// 检查数据库中是否已经存在相同跟踪号的包裹
if (packageDatabase.containsKey(trackingNumber)) {
return false; // 如果已存在,则返回入库失败
}
// 否则将包裹信息存入数据库
packageDatabase.put(trackingNumber, pkg);
return true; // 入库成功
}
// 模拟获取包裹信息
public Package getPackage(String trackingNumber) {
return packageDatabase.get(trackingNumber);
}
}
public class UniqueIndexIdempotentExample {
public static void main(String[] args) {
PackageService packageService = new PackageService();
// 第一次尝试入库同一个跟踪号的包裹
Package pkg1 = new Package("123456789", "Alice");
boolean result1 = packageService.receivePackage(pkg1);
System.out.println("First receive result: " + result1); // 应该是 true
// 第二次尝试入库同一个跟踪号的包裹
Package pkg2 = new Package("123456789", "Bob");
boolean result2 = packageService.receivePackage(pkg2);
System.out.println("Second receive result: " + result2); // 应该是 false
// 获取包裹信息示例
Package storedPackage = packageService.getPackage("123456789");
System.out.println("Stored package recipient: " + storedPackage.getRecipient());
}
}
//运行结果:
First receive result: true
Second receive result: false
Stored package recipient: Alice
这段代码中,第一次入库 (result1):首次尝试入库包裹 “123456789”,入库成功,返回 true。第二次入库 (result2):再次尝试入库相同跟踪号的包裹,但已经存在该跟踪号的包裹信息,入库失败,返回 false。
需要注意的是:
唯一索引的方案并不能直接保证更新操作的幂等性。并且需要保证对于相同的两次请求,通过某种机制生成的唯一索引列值是相同的。