腾讯二次元专访:如何保证接口的idempotency?如何实现高并发下的接口idempotency?
什么是接口幂等性
接口幂等性这一概念源于数学,原意是指一个操作如果连续执行多次所产生的结果与仅执行一次的效果相同,那么我们就称这个操作是幂等的。在互联网领域,特别是在Web服务、API设计和分布式系统中,接口幂等性具有非常重要的意义。
具体到HTTP接口或者服务间的API调用,接口幂等性就可以理解为当客户端对同一接口发起多次相同的请求时,服务端系统也应该确保只执行一次相应的操作,并且不论接收到了多少次请求,系统的状态变更始终是一致的,不会因为重复的请求而导致数据的错误。
比如我们常常遇到的订单创建,支付等业务。
如果一个“创建订单”接口实现了幂等性,当收到两次同样的创建请求时,系统应该要么拒绝第二个请求(因为它已经是重复请求),要么确保只有一个订单被创建,而不是两个完全一样的订单。
对于一个“支付”接口,幂等性要求即便用户由于网络原因反复点击支付按钮,服务端也只会扣除用户账户一次金额,避免重复扣费。
导致接口幂等性问题的原因
要向杜绝幂等性,那么我们就要之道导致接口幂等性问题的原因有哪些。接口幂等性问题通常由以下多种原因引起:
网络波动不稳定:
网络通信中的丢包、延迟等情况可能导致客户端未收到服务端的响应或服务端未收到客户端的请求,此时客户端可能会重试发送请求,导致接口被重复调用。
用户操作:
用户快速重复点击导致,例如用户在等待响应时,由于不确定是否操作成功,可能会多次点击提交按钮,进而发送多次相同的请求。再比如页用户频繁刷新页面,尤其是在某些提交操作尚未完成时,刷新页面可能会重新发送请求。还有用户可能在浏览器上点击回退然后再重复之间的提交操作,这都可能会导致重新发送请求。
重试机制:
在高可用性设计中,客户端常常设置有重试机制,当请求失败或超时时会自动重新发起请求。而在分布式系统中,服务间调用也可能有重试策略,以应对临时故障。比如Nginx重试,RPC重试,或者调用方业务层中进行重试。
定时任务或异步处理:
在定时任务中如果定时任务调度或逻辑设计不当,可能会导致同一任务被执行多次。或者在消息队列中,消息可能会因为异常等原因被重复消费。
并发控制:
缺乏有效的并发控制手段,导致在并发环境下,针对同一资源的操作被多次执行。
总的来说,导致接口幂等性问题可以粗略的归类于两种情况:前端调用以及服务端调用,那么我们可以针对这两种情况看一下如何去保证接口幂等。
如何保证接口幂等?
前端调用
页面控制
页面调用接口时可以通过禁用(如按钮置灰或显示加载状态)防止用户在请求未完成前重复点击,从而减少不必要的重复请求和可能的数据冲突。虽然在前端进行按钮置灰等操作可以辅助提高系统的幂等性表现,但是这个方式只是从用户体验和用户行为控制的角度来避免重复提交的一种方法,并没有从系统设计层面完全解决接口本身的幂等性问题。
使用RPG模式
PRG(POST/Redirect/GET)模式是一种前端交互策略,旨在解决用户刷新页面时可能导致表单数据重复提交的问题。它巧妙地利用了HTTP协议的特性,具体的交互流程如下:
用户在网页表单中填写数据,并通过POST请求将其发送至服务器进行处理,例如创建新资源或更新现有数据。
服务器接收到POST请求后,对提交的数据进行有效处理和持久化存储,并在操作成功后不直接返回处理结果,而是通过HTTP响应码302或303实现重定向,指示客户端发起一个新的GET请求去访问一个特定的URL。
客户端遵照服务器的重定向指示,自动发送GET请求访问新的URL,此时返回的页面将展示之前POST操作处理完毕的结果。
当用户在此后刷新页面时,浏览器只会按照常规方式重新发起GET请求,而非重新提交POST数据,因此有效地避免了重复提交引发的潜在问题
Token机制
Token机制是一种广泛应用互联网领域的认证与授权方法,特别是Web服务系统。token可以理解为一种安全凭证,它是由服务端生成并颁发给客户端的一段经过加密处理的字符串或数据结构,用来代表用户的某种状态或权限。
通过Token机制,我们可以解决接口幂等性问题。在接口中,我们允许重复提交,但是要保证重复提交不产生副作用,比如点击n次只产生一条记录,客户端每次请求都需要携带一个唯一的Token,而服务器则验证这个Token的有效性。如果服务器收到了一个已经使用过的Token就会认为这是一个重复请求并拒绝处理,从而确保接口的幂等性具体流握如下Token机制是一种常用的方法,用于确保接口的幂等性和防止重复请求。具体流程如下:
生成Token
当用户开始执行一个需要确保幂等性的操作(如支付、下单、更新用户信息等)时,服务端会生成一个唯一的、有时效性的token。这个token可以是一个随机字符串或者带有时间戳和其他相关信息的哈希值,确保其唯一性。
存储Token
生成的token会被存储在服务端的一个临时存储介质中,如Redis、Memcached或数据库,同时设置一个合理的过期时间(例如15分钟)。
传递Token
将生成的token返回给客户端,客户端在进行后续的API调用时,需将此token作为请求参数或放在请求头中一并发送给服务端。
验证Token
服务端在接收到带有token的请求时,首先检查token是否存在并且有效(未过期且未被使用过)。如果token有效且未被使用,则执行相应的业务逻辑,并在执行完成后立即从存储介质中移除或标记为已使用。若token已失效或已被使用,则拒绝此次请求,返回相应的错误提示,确保同一个操作不会被执行两次。
限制并发
在并发场景下,通过原子操作(如Redis的SETNX命令)确保在验证token有效的同时,将其删除或更新状态,避免多个请求同时通过验证。
image.png
image.png
服务端控制
在服务端接口处理逻辑时,可以通过通过一些特定的标识符或请求参数来校验请求的幂等性,以确保同样的请求不会被重复处理。
唯一标识符
客户端每次发起请求会携带一个全局唯一的标识符。服务器接收到请求后就会对这个标识符进行检查,若服务器发现该标识符已经在系统中存在,表明这是一个重复请求,此时服务器可以选择忽略该请求,或者向客户端返回已处理过相同请求的结果信息。若服务器未找到该标识符存在于系统内,则认定该请求为新请求,服务器将继续对其进行正常处理,并将此唯一标识符保存至系统中,以便于后续对接收的请求进行有效性校验,防止同一请求的重复处理。比如我们在要求上游ERP系统对接订单平台时就会要求上游传递一个账号下全局唯一的一个参考单号,这个参考单号一个很重要的作用就是保证接口幂等性。
请求参数
某些请求参数确实可以用来辅助校验请求的幂等性。例如,时间戳可以作为一种可能的请求参数,在处理请求时,服务器可以通过比较时间戳与服务器当前时间来判断请求的有效性。若时间戳与当前时间之间的差异超出预设的合理范围(如几秒钟到几分钟不等,具体阈值视业务场景而定),服务器可以推测该请求可能是由于网络延迟或者其他原因导致的重复提交。
单纯依靠时间戳来判断幂等性和重复请求并不完全准确,因为不同的客户端时间可能并不精确同步,而且时间戳本身无法保证全局唯一性。但是它可以作为一种有效的辅助手段来减少重复处理的可能性
状态机设计
对于状态转移类的操作类型的业务,可采用状态机设计,每次请求只允许合法的状态变迁,非法状态变迁(如已经完成的订单不允许再次支付)将被拒绝。
乐观锁
在更新数据时,可以通过版本号或时间戳等机制判断数据是否已被修改,防止因并发请求导致的多次更新问题。具体做法:
在数据库表中增加一个版本号字段(version)或者时间戳字段(timestamp)。
客户端第一次请求时获取数据的版本号或时间戳。
客户端发起更新操作时,将上次读取的版本号或时间戳一起发送回服务器。
服务器在执行更新操作前,首先检查当前数据库中的版本号或时间戳是否与客户端提交的一致。
如果一致,说明在这期间数据没有被其他事务修改过,于是更新数据并递增版本号或更新时间戳。
如果不一致,说明数据已经被修改过,此时服务器拒绝本次更新请求,返回错误提示,客户端可以根据错误信息决定是否重新获取最新数据再尝试更新。
通过这种方式,即使客户端因为网络原因或其他因素导致同一请求被多次发送,乐观锁机制能确保只有在数据未被其他事务修改的前提下,才会执行更新操作,从而达到接口幂等的效果。
实现幂等性方案示例
从上述的几种解决幂等性问题的方案来看,使用token机制可以保证在不同请求动作下的幂等性。所以我们以此作为方案作为示例方案。
准备工作
我们使用Redis保存Token令牌,引入SpringBoot,Redis,ULID相关的依赖
spring.redis.database=0
spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.redis.password=
spring.redis.pool.max-active=8
spring.redis.pool.max-wait=-1
spring.redis.pool.max-idle=8
spring.redis.pool.min-idle=0
spring.redis.timeout=60
server.port=8080
server.servlet.context-path=/coderacademy
生成Token令牌
使用ULID生成随机字符串,然后将其保存在Redis当中。这里以idempotent_token+账户+请求操作类型+token作为key。
private StringRedisTemplate stringRedisTemplate;
/**
- 存入 Redis 的 Token 键的前缀
*/
private static final String IDEMPOTENT_TOKEN_PREFIX = "idempotent_token:%s:$s:%s";
/**
- 生成token令牌
- @param accountSecret 账户令牌
- @param operatorType 接口请求类型,可以是接口url或者其他可以区分接口服务类型的值
- @return token令牌
*/
@Override
public String generateToken(String accountSecret, String operatorType) {
// 创建或获取ULID生成器实例
long timestampInMillis = LocalDateTime.now().atZone(ZoneOffset.systemDefault()).toInstant().toEpochMilli();
Ulid ulid = UlidCreator.getUlid(timestampInMillis);
String token = ulid.toString();
// 设置存入 Redis 的 Key
String key = String.format(IDEMPOTENT_TOKEN_PREFIX, accountSecret, operatorType, token);
// 存储 Token 到 Redis,且设置过期时间为5分钟
stringRedisTemplate.opsForValue().set(key, accountSecret, 5, TimeUnit.MINUTES);
// 返回 Token
return token;
}
校验Token令牌
这里我们使用Redis执行Lua命令去查找以及删除key,Lua 表达式能保证命令执行的原子性。
/**
* 验证 Token 正确性
*
* @param token token 字符串
* @param operatorType 接口请求类型,可以是接口url或者其他可以区分接口服务类型的值
* @return 验证结果
*/
private boolean validToken(String token, String accountSecret, String operatorType) {
// 设置 Lua 脚本,其中 KEYS[1] 是 key,KEYS[2] 是 value
String script = "if redis.call('get', KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end";
RedisScript
// 根据 Key 前缀拼接 Key
String key = String.format(IDEMPOTENT_TOKEN_PREFIX, accountSecret, operatorType, token);
// 执行 Lua 脚本
Long result = stringRedisTemplate.execute(redisScript, Arrays.asList(key, operatorType));
// 根据返回结果判断是否成功成功匹配并删除 Redis 键值对,若果结果不为空和0,则验证通过
if (result != null && result != 0L) {
System.out.println(String.format("验证 token=%s,key=%s,value=%s 成功", token, key, operatorType));
return true;
}
System.err.println(String.format("验证 token=%s,key=%s,value=%s 失败", token, key, operatorType));
return false;
}
业务代码以及接口
我们在实现模拟创建订单的服务,在创建订单之前,首先校验token令牌。
/**
- 创建订单接口
- @param requestVO 创建订单参数
- @param accountSecret 账户令牌
- @param token token令牌
- @return 生成的订单号
*/
@Override
public String createOrder(OrderCreateRequestVO requestVO, String accountSecret, String token) {
// 根据 Token 和与用户相关的信息到 Redis 验证是否存在对应的信息
boolean result = validToken(token, accountSecret, "createOrder");
if (!result){
// 这里需要自定义异常,统一处理异常,再统一响应返回
throw new RuntimeException("重复的请求");
}
// 根据验证结果响应不同信息
return "Success";
}
校验如果不存在token,则说明请求时重复请求,直接抛出异常,由统一异常管理,直接返回客户端请求失败的错误信息。关于SpringBoot中统一异常处理,统一结果响应,请查看:
我们在定义获取Token令牌的接口,以及创建订单的接口
@RestController
@RequestMapping("order")
public class OrderController {
private IOrderService orderService;
/**
* 获取token接口
* @param secret 账户令牌
* @return
*/
@GetMapping("getToken")
public String getToken(@RequestHeader("secret") String secret){
return orderService.generateToken(secret, "createOrder");
}
/**
* 创建订单接口
* @param requestVO 参数
* @param token token令牌
* @param secret 账户令牌
* @return 响应信息
*/
@PostMapping("create")
public OrderCreateResponseVO createOrder(@RequestBody OrderCreateRequestVO requestVO,
@RequestHeader("token") String token,
@RequestHeader("secret") String secret){
OrderCreateResponseVO responseVO = new OrderCreateResponseVO();
String result = orderService.createOrder(requestVO, secret, token);
responseVO.setSuccess(Boolean.TRUE);
responseVO.setMsg(result);
return responseVO;
}
@Autowired
public void setOrderService(IOrderService orderService) {
this.orderService = orderService;
}
}
我们使用Apifox模拟3个请求并发操作
image.png
image.png
执行结果如下:
image.png
image.png
控制台打印日志如下
image.png
image.png
可以看见只有1个请求成功了,并且控制台中打印只有一个token校验成功。
总结
幂等性是开发当中很常见也很重要的一个需求,尤其是订单,支付以及与金钱挂钩的服务,保证接口幂等性尤其重要。在实际开发中,我们需要针对不同的业务场景我们需要灵活的选择幂等性的实现方式:
如果是web服务,客户端可以采取在页面上使用按钮置灰禁用,使用PRG模式,或者搭配后端的Token令牌进行解决。
在服务端,我们可以采取唯一标识符,乐观锁,Token令牌,状态机等校验方式。
最后强调一下,实现幂等性需要先理解自身业务需求,根据业务逻辑来实现这样才合理,处理好其中的每一个结点细节,完善整体的业务流程设计,才能更好的保证系统的正常运行。
本文已收录于我的个人博客:码农Academy的博客,专注分享Java技术干货,包括Java基础、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中间件、架构设计、面试题、程序员攻略等
上一篇: 如何确保信息消费中的幂等性
推荐阅读
-
腾讯二次元专访:如何保证接口的idempotency?如何实现高并发下的接口idempotency?
-
Grid++Report 锐浪报表开发常见问题解答集锦-报表设计 问:怎样在设计时打印预览报表? 答:为了及时查看报表的设计效果,Grid++Report 报表设计应用程序提供了四种查看视图:普通视图、页面视图、预览视图与查询视图。通过窗口下边的 Tab 按钮可以在四种视图中任意切换。在预览视图中查看报表的打印预览效果,在查询视图中查看报表的查询显示效果。如果在报表的记录集提供了数据源连接串与查询 SQL,在进入预览视图与查询视图时会利用数据源连接串与查询 SQL 从数据源中自动取数,否则 Grid++Report 将自动生成模拟数据进行模拟打印预览与查询显示。注意:在预览视图与查询视图中看到的报表运行结果有可能与在你程序中的最终运行结果有差异,因为在报表的生成过程中我们可以在程序中对报表的生成行为进行一定的控制。 问:怎样用 Grid++Report 设计交叉表? 答:Grid++Report 没有提供专门实现交叉表的功能,其它的报表构件提供的交叉表功能一般也比较死板和功能有限。利用 Grid++Report 的编程接口可以做出灵活多变,功能丰富的交叉表。示例程序 CrossTab 就是一个实现交叉表的例子程序,认真领会此例子程序,你就可以做出自己想要各种交叉表,并能提取一些共用代码,便于重复使用。 问:怎样设置整个报表的缺省字体? 答:设置报表主对象的字体属性,也就是设置了整个报表的缺省字体。如果改变报表主对象的字体属性,则没有专门的设置字体属性的子对象的字体属性也跟随改变。同样每个报表节与明细网格也有字体属性,他们的字体属性也就是其拥有的子对象的缺省字体。 问:怎样在打印时限制一页的输出行数? 答:设定明细网格的内容行的‘每页行数(RowsPerPage)’属性即可。另外要注意‘调节行高(AdjustRowHeight)’属性值:为真时根据页面的输出高度自动调整行的高度,使整个页面的输出区域充满。为假时按设计时的高度输出行。 问:怎样显示中文大写金额? 答:将对象的“格式(Format)”属性设为 “$$” 及可,可以设置格式的对象有:字段(IGRField)、参数(IGRParameter)、系统变量(IGRSystemVarBox)与综合文字框(IGRMemoBox),其中综合文字框是在报表式上设格式。 问:能否实现自定义纸张与票据打印? 答:Grid++Report 完全支持自定义纸张的打印,只要在报表设定时在页面设置中选定自定义纸张,并指定准确的纸张尺寸。当然要在最终输出时得道合适的打印结果,输出打印机必须支持自定义纸张打印。Windows2000/XP/2003 操作系统上可以在打印机上定义自定义纸张,也可以采用这种方式实现自定义纸张打印。 问:怎样实现 0 值不打印? 答:直接设置格式串就可以,在“数字格式”设置对话框中选定“0 不显示”,就会得到合适的格式串。也可以通过直接录入格式串来指定 0 不显示,但格式串必须符合 Grid++Report 的规定格式。另一种实现办法是在报表获取明细记录数据时,在 BeforePostRecord 事件中将值为零的字段设为空,调用字段的 Clear 方法将字段置为空。 问:怎样实现多栏报表? 答:在明细网格上设‘页栏数(PageColumnCount)’属性值大于 1 即可。通过 Grid++Report 的“页栏输出顺序”还可以指定多栏报表的输出顺序是“先从上到下”还是“先从左到右”。 问:如何实现票据套打? 答:Grid++Report 为实现票据套打做了很多专门的安排:报表设计器提供了页面设计模式,按照设定的纸张尺寸显示设计面板,如果将空白票据的扫描图设为设计背景图,在定位报表内容的输出位置会非常方便。报表部件可以设定打印类别,非套打输出的内容在套打打印模式下就不会输出。 问:Grid++Report 有没有横向分页功能? 答:回答是肯定的,在列的总宽度超过打印页面的输出宽度时,Grid++Report 可以另起新页输出剩余的列,如果左边存在锁定列,锁定列可以在后面的新页中重复输出,这样可以保证关键数据列在每一页都有输出。仔细体会 Grid++Report 提供的多种打印适应策略,选用最合适的方式。Grid++Report 的多种打印适应策略为开发动态报表提供了很好的支持。 问:怎样实现报表本页小计功能? 答:定义一个报表分组,将本分组定义为页分组,在本分组的分组头与分组尾上定义统计。页分组就是在每页产生一个分组项,在每页的上端与下端都会分别显示页分组的分组头与分组尾,页分组不用定义分组依据字段。 报表运行 问:怎样与数据库建立连接? 答:如果在设计报表时指定了数据集的数据源连接串与查询 SQL 语句,Grid++Report 采用拉模式直接从数据源取得报表数据,Grid++Report 利用 OLE DB 从数据源取数,OLE DB 提供了广泛的数据源操作能力。如果 Grid++Report 的数据来源采用推模式,即 Grid++Report 不直接与数据库建立连接,各种编程语言/平台都提供了很好的数据库连接方式,并且易于操作,应用程序在报表主对象(IGridppReport)的 FetchRecord 事件中将数据传入,例子程序提供了各种编程语言填入数据的通用方法,对C++Builder 和 Delphi 还进行了专门的包装,直接关联 TDataSet 对象也可以将 TDataSet 对象中的数据传给报表。 问:打印时能否对打印纸张进行自适应?支持表格的折行打印吗? 答:Grid++Report 在打印时采用多种适应策略,通过设置明细网格(IGRDetailGrid)的‘打印策略(PrintAdaptMethod)’属性指定打印策略。(1)丢弃:按设计时列的宽度输出,超出范围的内容不显示。(2)绕行:按设计时列的宽度输出,如果在当前行不能完整输出,则另起新行进行输出。(3)缩放适应:对所有列的输出宽度进行按比例地缩放,使总宽度等于页面的输出宽度。(4)缩小适应:如果列的总宽度小于页面的输出宽度,对所有列的输出宽度进行按比例地缩小,使总宽度等于页面的输出宽度。(5)横向分页:超范围的列在新页中输出。(6)横向分页并重复锁定列。 问:如何改变缺省打印预览窗口的窗口标题? 答:改变报表主对象的‘标题(Title)’属性即可。 问:利用集合对象的编程接口取子对象的接口引用,但不是自己期望的结果。 答:Grid++Report中所有集合对象的下标索引都是从 1 开始,另按对象的名称查找对象的接口引用时,名称字符是不区分大小写的。 问:怎样在运行时控制报表中各个对象的可见性?即怎样在运行时显示或隐藏对象? 答:在报表主对象(GridppReport)的 SectionFormat 事件中设定相应报表子对象的可见(Visible)属性即可。 问:报表主对象重新载入数据,设计器中为什么没有反映新载入的数据? 答:应调用 IGRDesigner 的 Reload 方法。 问:怎样实现不进入打印预览界面,直接将报表打印出来?