现如今很多系统都会基于分布式或微服务思想完成对系统的架构设计。那么在这一个系统中,就会存在若干个微服务,而且服务间也会产生相互通信调用。那么既然产生了服务调用,就必然会存在服务调用延迟或失败的问题。当出现这种问题,服务端会进行重试等操作或客户端有可能会进行多次点击提交。如果这样请求多次的话,那最终处理的数据结果就一定要保证统一,如支付场景。此时就需要通过保证业务幂等性方案来完成。
什么是幂等性
幂等是一个数学与计算机学概念,即
f(n) = 1^n
,无论 n 为多少,f (n) 的值永远为 1,在数学中某一元运算为幂等时,其作用在任一元素两次后会和其作用一次的结果相同。
在编程开发中,对于幂等的定义为:无论对某一个资源操作了多少次,其影响都应是相同的。 换句话说就是:在接口重复调用的情况下,对系统产生的影响是一样的,但是返回值允许不同,如查询。
幂等函数或幂等方法是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。
幂等性不仅仅只是一次或多次操作对资源没有产生影响,还包括第一次操作产生影响后,以后多次操作不会再产生影响。并且幂等关注的是是否对资源产生影响,而不关注结果。
幂等性维度
幂等性设计主要从两个维度进行考虑:空间、时间。
同时对于幂等的使用一般都会伴随着出现锁的概念,用于解决并发安全问题。
以 SQL 为例
select * from table where id=1
。此 SQL 无论执行多少次,虽然结果有可能出现不同,都不会对数据产生改变,具备幂等性。insert into table(id,name) values(1,'heima')
。此 SQL 如果 id 或 name 有唯一性约束,多次操作只允许插入一条记录,则具备幂等性。如果不是,则不具备幂等性,多次操作会产生多条数据。update table set score=100 where id = 1
。此 SQL 无论执行多少次,对数据产生的影响都是相同的。具备幂等性。update table set score=50+score where id = 1
。此 SQL 涉及到了计算,每次操作对数据都会产生影响。不具备幂等性。delete from table where id = 1
。此 SQL 多次操作,产生的结果相同,具备幂等性。什么是接口幂等性
在
HTTP/1.1
中,对幂等性进行了定义。
它描述了一次和多次请求某一个资源对于资源本身应该具有同样的结果(网络超时等问题除外),即第一次请求的时候对资源产生了副作用,但是以后的多次请求都不会再对资源产生副作用。
这里的副作用是不会对结果产生破坏或者产生不可预料的结果。也就是说,其任意多次执行对资源本身所产生的影响均与一次执行的影响相同。
为什么需要实现幂等性
使用幂等性最大的优势在于使接口保证任何幂等性操作,免去因重试等造成系统产生的未知的问题。
在接口调用时一般情况下都能正常返回信息不会重复提交,不过在遇见以下情况时可以就会出现问题:
前端重复提交表单
在填写一些表格时候,用户填写完成提交,很多时候会因网络波动没有及时对用户做出提交成功响应,致使用户认为没有成功提交,然后一直点提交按钮,这时就会发生重复提交表单请求。
用户恶意进行刷单
例如在实现用户投票这种功能时,如果用户针对一个用户进行重复提交投票,这样会导致接口接收到用户重复提交的投票信息,这样会使投票结果与事实严重不符。
接口超时重复提交
很多时候 HTTP 客户端工具都默认开启超时重试的机制,尤其是第三方调用接口时候,为了防止网络波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次。
消息进行重复消费
当使用 MQ 消息中间件时候,如果发生消息中间件出现错误未及时提交消费信息,导致发生重复消费。
引入幂等性后对系统有什么影响
幂等性是为了简化客户端逻辑处理,能放置重复提交等操作,但却增加了服务端的逻辑复杂性和成本,其主要是:
所以在使用时候需要考虑是否引入幂等性的必要性,根据实际业务场景具体分析,除了业务上的特殊要求外,一般情况下不需要引入的接口幂等性。
现在流行的 Restful 推荐的几种 HTTP 接口方法中,分别存在幂等行与不能保证幂等的方法,如下:
HTTP 协议语义幂等性
HTTP 协议有两种方式:RESTFUL、SOA。现在对于 WEB API,更多的会使用 RESTFUL 风格定义。为了更好的完成接口语义定义,HTTP 对于常用的四种请求方式也定义了幂等性的语义。
综上所述,这些仅仅只是 HTTP 协议建议在基于 RESTFUL 风格定义 WEB API 时的语义,并非强制性。同时对于幂等性的实现,肯定是通过前端或服务端完成。
业务问题抛出
在业务开发与分布式系统设计中,幂等性是一个非常重要的概念,有非常多的场景需要考虑幂等性的问题,尤其对于现在的分布式系统,经常性的考虑重试、重发等操作,一旦产生这些操作,则必须要考虑幂等性问题。以交易系统、支付系统等尤其明显,如:
在电商系统中还有非常多的场景需要保证幂等性。但是一旦考虑幂等后,服务逻辑务必会变的更加复杂。因此是否要考虑幂等,需要根据具体业务场景具体分析。而且在实现幂等时,还会把并行执行的功能改为串行化,降低了执行效率。
此处以下单减库存为例,当用户生成订单成功后,会对订单中商品进行扣减库存。 订单服务会调用库存服务进行库存扣减。库存服务会完成具体扣减实现。
现在对于功能调用的设计,有可能出现调用超时,因为出现如网络抖动,虽然库存服务执行成功了,但结果并没有在超时时间内返回,则订单服务也会进行重试。那就会出现问题,stock 对于之前的执行已经成功了,只是结果没有按时返回。而订单服务又重新发起请求对商品进行库存扣减。 此时出现库存扣减两次的问题。 对于这种问题,就需要通过幂等性进行结果。
解决方案
对于幂等的考虑,主要解决两点前后端交互与服务间交互。这两点有时都要考虑幂等性的实现。从前端的思路解决的话,主要有三种:前端防重、PRG 模式、Token 机制。
前端防重
通过前端防重保证幂等是最简单的实现方式,前端相关属性和 JS 代码即可完成设置。可靠性并不好,有经验的人员可以通过工具跳过页面仍能重复提交。主要适用于表单重复提交或按钮重复点击。
PRG 模式
PRG 模式即 POST-REDIRECT-GET。当用户进行表单提交时,会重定向到另外一个提交成功页面,而不是停留在原先的表单页面。这样就避免了用户刷新导致重复提交。同时防止了通过浏览器按钮前进 / 后退导致表单重复提交。是一种比较常见的前端防重策略。
Token 模式
通过 token 机制来保证幂等是一种非常常见的解决方案,同时也适合绝大部分场景。该方案需要前后端进行一定程度的交互来完成。
针对客户端连续点击或者调用方的超时重试等情况,例如提交订单,此种操作就可以用
Token
的机制实现防止重复提交。
简单的说就是调用方在调用接口的时候先向后端请求一个全局 ID(Token)
,请求的时候携带这个全局 ID
一起请求(Token
最好将其放到 Headers
中),后端需要对这个 Token
作为 Key
,用户信息作为 Value
到 Redis
中进行键值内容校验,如果 Key
存在且 Value
匹配就执行删除命令,然后正常执行后面的业务逻辑。如果不存在对应的 Key
或 Value
不匹配就返回重复执行的错误信息,这样来保证幂等操作。
适用操作
使用限制
Token
串Redis
进行数据效验主要流程
ID
或者 UUID
串。Headers
中,执行业务请求带上该 Headers
。Headers
中拿到 Token,然后根据 Token 到 Redis 中查找该 key
是否存在。key
进行判断,如果存在就将该 key
删除,然后正常执行业务逻辑。如果不存在就抛异常,返回重复提交的错误信息。注意,在并发情况下,执行 Redis 查找数据与删除需要保证原子性,否则很可能在并发下无法保证幂等性。其实现方法可以使用分布式锁或者使用
Lua
表达式来注销查询与删除操作。
实现流程
通过 token 机制来保证幂等是一种非常常见的解决方案,同时也适合绝大部分场景。该方案需要前后端进行一定程度的交互来完成。
业务执行时机
先执行业务再删除 token
但是现在有一个问题,当前是先执行业务再删除 token。
在高并发下,很有可能出现第一次访问时 token 存在,完成具体业务操作。但在还没有删除 token 时,客户端又携带 token 发起请求,此时,因为 token 还存在,第二次请求也会验证通过,执行具体业务操作。
对于这个问题的解决方案的思想就是并行变串行。会造成一定性能损耗与吞吐量降低。
先删除 token 再执行业务
那如果先删除 token 再执行业务呢?其实也会存在问题,假设具体业务代码执行超时或失败,没有向客户端返回明确结果,那客户端就很有可能会进行重试,但此时之前的 token 已经被删除了,则会被认为是重复请求,不再进行业务处理。
这种方案无需进行额外处理,一个 token 只能代表一次请求。一旦业务执行出现异常,则让客户端重新获取令牌,重新发起一次访问即可。推荐使用先删除 token 方案
但是无论先删 token 还是后删 token,都会有一个相同的问题。每次业务请求都回产生一个额外的请求去获取 token。但是,业务失败或超时,在生产环境下,一万个里最多也就十个左右会失败,那为了这十来个请求,让其他九千九百多个请求都产生额外请求,就有一些得不偿失了。虽然 redis 性能好,但是这也是一种资源的浪费。
基于业务实现
生成 Token
修改 token_service_order 工程中 OrderController,新增生成令牌方法 genToken
- @Autowired
- private IdWorker idWorker;
-
- @Autowired
- private RedisTemplate redisTemplate;
-
- @GetMapping("/genToken")
- public String genToken(){
-
- String token = String.valueOf(idWorker.nextId());
-
- redisTemplate.opsForValue().set(token,0,30, TimeUnit.MINUTES);
-
- return token;
- }
新增接口
修改 token_service_api 工程,新增 OrderFeign 接口。
- @FeignClient(name = "order")
- @RequestMapping("/order")
- public interface OrderFeign {
-
- @GetMapping("/genToken")
- public String genToken();
- }
获取 token
修改 token_web_order 工程中 WebOrderController,新增获取 token 方法
- @RestController
- @RequestMapping("worder")
- public class WebOrderController {
-
- @Autowired
- private OrderFeign orderFeign;
-
- /**
- * 服务端生成token
- * @return
- */
- @GetMapping("/genToken")
- public String genToken(){
-
- String token = orderFeign.genToken();
-
- return token;
- }
-
- }
拦截器
修改 token_common,新增 feign 拦截器
- @Component
- public class FeignInterceptor implements RequestInterceptor {
-
- @Override
- public void apply(RequestTemplate requestTemplate) {
-
- //传递令牌
- RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
-
- if (requestAttributes != null){
-
- HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
-
- if (request != null){
-
- Enumeration<String> headerNames = request.getHeaderNames();
-
- while (headerNames.hasMoreElements()){
-
- String headerName = headerNames.nextElement();
-
- if ("token".equals(headerName)){
-
- String headerValue = request.getHeader(headerName);
-
- //传递token
- requestTemplate.header(headerName,headerValue);
- }
- }
- }
- }
- }
- }
启动类
修改 token_web_order 启动类
- @Bean
- public FeignInterceptor feignInterceptor(){
- return new FeignInterceptor();
- }
新增订单
修改 token_service_order 中 OrderController,新增添加订单方法
- /**
- * 生成订单
- * @param order
- * @return
- */
- @PostMapping("/genOrder")
- public String genOrder(@RequestBody Order order, HttpServletRequest request){
-
- //获取令牌
- String token = request.getHeader("token");
-
- //校验令牌
- try {
- if (redisTemplate.delete(token)){
-
- //令牌删除成功,代表不是重复请求,执行具体业务
- order.setId(String.valueOf(idWorker.nextId()));
- order.setCreateTime(new Date());
- order.setUpdateTime(new Date());
- int result = orderService.addOrder(order);
-
- if (result == 1){
- System.out.println("success");
- return "success";
- }else {
- System.out.println("fail");
- return "fail";
- }
- }else {
-
- //删除令牌失败,重复请求
- System.out.println("repeat request");
- return "repeat request";
- }
- }catch (Exception e){
- throw new RuntimeException("系统异常,请重试");
- }
- }
修改 token_service_order_api 中 OrderFeign。
- @FeignClient(name = "order")
- @RequestMapping("/order")
- public interface OrderFeign {
-
- @PostMapping("/genOrder")
- public String genOrder(@RequestBody Order order);
-
- @GetMapping("/genToken")
- public String genToken();
- }
修改 token_web_order 中 WebOrderController,新增添加订单方法
- /**
- * 新增订单
- */
- @PostMapping("/addOrder")
- public String addOrder(@RequestBody Order order){
-
- String result = orderFeign.genOrder(order);
-
- return result;
- }
测试
通过 postman 获取令牌,将令牌放入请求头中。开启两个 postman tab 页面。同时添加订单,可以发现一个执行成功,另一个重复请求。
{"id":"123321","totalNum":1,"payMoney":1,"payType":"1","payTime":"2020-05-20","receiverContact":"heima","receiverMobile":"15666666666","receiverAddress":"beijing"}
基于自定义注解实现
直接把 token 实现嵌入到方法中会造成大量重复代码的出现。因此可以通过自定义注解将上述代码进行改造。在需要保证幂等的方法上,添加自定义注解即可。
自定义注解
在 token_common 中新建自定义注解 Idemptent
- /**
- * 幂等性注解
- */
- @Target({ElementType.METHOD})
- @Retention(RetentionPolicy.RUNTIME)
- public @interface Idemptent {
- }
创建拦截器
在 token_common 中新建拦截器
- public class IdemptentInterceptor implements HandlerInterceptor {
-
- @Override
- public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
-
- if (!(handler instanceof HandlerMethod)) {
- return true;
- }
-
- HandlerMethod handlerMethod = (HandlerMethod) handler;
- Method method = handlerMethod.getMethod();
-
- Idemptent annotation = method.getAnnotation(Idemptent.class);
- if (annotation != null){
- //进行幂等性校验
- checkToken(request);
- }
-
- return true;
- }
-
-
- @Autowired
- private RedisTemplate redisTemplate;
-
- //幂等性校验
- private void checkToken(HttpServletRequest request) {
- String token = request.getHeader("token");
- if (StringUtils.isEmpty(token)){
- throw new RuntimeException("非法参数");
- }
-
- boolean delResult = redisTemplate.delete(token);
- if (!delResult){
- //删除失败
- throw new RuntimeException("重复请求");
- }
- }
-
- @Override
- public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
-
- }
-
- @Override
- public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
-
- }
- }
配置拦截器
修改 token_service_order 启动类,让其继承 WebMvcConfigurerAdapter
- @Bean
- public IdemptentInterceptor idemptentInterceptor() {
- return new IdemptentInterceptor();
- }
-
- @Override
- public void addInterceptors(InterceptorRegistry registry) {
- //幂等拦截器
- registry.addInterceptor(idemptentInterceptor());
- super.addInterceptors(registry);
- }
添加注解
更新 token_service_order 与 token_service_order_api,新增添加订单方法,并且方法添加自定义幂等注解
- @Idemptent
- @PostMapping("/genOrder2")
- public String genOrder2(@RequestBody Order order){
-
- order.setId(String.valueOf(idWorker.nextId()));
- order.setCreateTime(new Date());
- order.setUpdateTime(new Date());
- int result = orderService.addOrder(order);
-
- if (result == 1){
- System.out.println("success");
- return "success";
- }else {
- System.out.println("fail");
- return "fail";
- }
- }
测试
获取令牌后,在 jemeter 中模拟高并发访问,设置 50 个并发访问
新增一个 http request,并设置相关信息
添加 HTTP Header Manager
测试执行,可以发现,只有一个请求是成功的,其他全部被判定为重复请求。 本文由
传智教育博学谷狂野架构师
教研团队发布。如果本文对您有帮助,欢迎
关注
和点赞
;如果您有任何建议也可留言评论
或私信
,您的支持是我坚持创作的动力。转载请注明出处!