幂等性介绍
现如今很多零碎都会基于分布式或微服务思维实现对系统的架构设计。那么在这一个零碎中,就会存在若干个微服务,而且服务间也会产生互相通信调用。那么既然产生了服务调用,就必然会存在服务调用提早或失败的问题。当呈现这种问题,服务端会进行重试等操作或客户端有可能会进行屡次点击提交。如果这样申请屡次的话,那最终解决的数据后果就肯定要保障对立,如领取场景。此时就须要通过保障业务幂等性计划来实现。
什么是幂等性
幂等是一个数学与计算机学概念,即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 API 接口幂等
当初风行的 Restful 举荐的几种 HTTP 接口办法中,别离存在幂等行与不能保障幂等的办法,如下:
HTTP协定语义幂等性
HTTP协定有两种形式:RESTFUL、SOA。当初对于WEB API,更多的会应用RESTFUL格调定义。为了更好的实现接口语义定义,HTTP对于罕用的四种申请形式也定义了幂等性的语义。
- GET:用于获取资源,屡次操作不会对数据产生影响,具备幂等性。留神不是后果。
- POST:用于新增资源,对同一个URI进行两次POST操作会在服务端创立两个资源,不具备幂等性。
- PUT:用于批改资源,对同一个URI进行屡次PUT操作,产生的影响和第一次雷同,具备幂等性。
- DELETE:用于删除资源,对同一个URI进行屡次DELETE操作,产生的影响和第一次雷同,具备幂等性
综上所述,这些仅仅只是HTTP协定倡议在基于RESTFUL格调定义WEB API时的语义,并非强制性。同时对于幂等性的实现,必定是通过前端或服务端实现。
业务问题抛出
在业务开发与分布式系统设计中,幂等性是一个十分重要的概念,有十分多的场景须要思考幂等性的问题,尤其对于当初的分布式系统,经常性的思考重试、重发等操作,一旦产生这些操作,则必须要思考幂等性问题。以交易系统、领取零碎等尤其显著,如:
- 当用户购物进行下单操作,用户操作屡次,但订单零碎对于本次操作只能产生一个订单。
- 当用户对订单进行付款,领取零碎不论呈现什么问题,应该只对用户扣一次款。
- 当领取胜利对库存扣减时,库存系统对订单中商品的库存数量也只能扣减一次。
- 当对商品进行发货时,也需保障物流零碎有且只能发一次货。
在电商零碎中还有十分多的场景须要保障幂等性。然而一旦思考幂等后,服务逻辑务必会变的更加简单。因而是否要思考幂等,须要依据具体业务场景具体分析。而且在实现幂等时,还会把并行执行的性能改为串行化,升高了执行效率。
此处以下单减库存为例,当用户生成订单胜利后,会对订单中商品进行扣减库存。 订单服务会调用库存服务进行库存扣减。库存服务会实现具体扣减实现。
当初对于性能调用的设计,有可能呈现调用超时,因为呈现如网络抖动,尽管库存服务执行胜利了,但后果并没有在超时工夫内返回,则订单服务也会进行重试。那就会呈现问题,stock对于之前的执行曾经胜利了,只是后果没有按时返回。而订单服务又从新发动申请对商品进行库存扣减。 此时呈现库存扣减两次的问题。 对于这种问题,就须要通过幂等性进行后果。
解决方案
对于幂等的思考,次要解决两点前后端交互与服务间交互。这两点有时都要思考幂等性的实现。从前端的思路解决的话,次要有三种:前端防重、PRG模式、Token机制。
前端防重
通过前端防重保障幂等是最简略的实现形式,前端相干属性和JS代码即可实现设置。可靠性并不好,有教训的人员能够通过工具跳过页面仍能反复提交。次要实用于表单反复提交或按钮反复点击。
PRG模式
PRG模式即POST-REDIRECT-GET。当用户进行表单提交时,会重定向到另外一个提交胜利页面,而不是停留在原先的表单页面。这样就防止了用户刷新导致反复提交。同时避免了通过浏览器按钮后退/后退导致表单反复提交。是一种比拟常见的前端防重策略。
Token模式
通过token机制来保障幂等是一种十分常见的解决方案,同时也适宜绝大部分场景。该计划须要前后端进行肯定水平的交互来实现。
Token防重实现
针对客户端间断点击或者调用方的超时重试等状况,例如提交订单,此种操作就能够用 Token
的机制实现避免反复提交。
简略的说就是调用方在调用接口的时候先向后端申请一个全局 ID(Token)
,申请的时候携带这个全局 ID
一起申请(Token
最好将其放到 Headers
中),后端须要对这个 Token
作为 Key
,用户信息作为 Value
到 Redis
中进行键值内容校验,如果 Key
存在且 Value
匹配就执行删除命令,而后失常执行前面的业务逻辑。如果不存在对应的 Key
或 Value
不匹配就返回反复执行的错误信息,这样来保障幂等操作。
实用操作
- 插入操作
- 更新操作
- 删除操作
应用限度
- 须要生成全局惟一
Token
串 - 须要应用第三方组件
Redis
进行数据效验
次要流程
- 服务端提供获取 Token 的接口,该 Token 能够是一个序列号,也能够是一个分布式
ID
或者UUID
串。 - 客户端调用接口获取 Token,这时候服务端会生成一个 Token 串。
- 而后将该串存入 Redis 数据库中,以该 Token 作为 Redis 的键(留神设置过期工夫)。
- 将 Token 返回到客户端,客户端拿到后应存到表单暗藏域中。
- 客户端在执行提交表单时,把 Token 存入到
Headers
中,执行业务申请带上该Headers
。 - 服务端接管到申请后从
Headers
中拿到 Token,而后依据 Token 到 Redis 中查找该key
是否存在。 - 服务端依据 Redis 中是否存该
key
进行判断,如果存在就将该key
删除,而后失常执行业务逻辑。如果不存在就抛异样,返回反复提交的错误信息。
留神,在并发状况下,执行 Redis 查找数据与删除须要保障原子性,否则很可能在并发下无奈保障幂等性。其实现办法能够应用分布式锁或者应用 Lua
表达式来登记查问与删除操作。
实现流程
通过token机制来保障幂等是一种十分常见的解决方案,同时也适宜绝大部分场景。该计划须要前后端进行肯定水平的交互来实现。
- 服务端提供获取token接口,供客户端进行应用。服务端生成token后,如果以后为分布式架构,将token寄存于redis中,如果是单体架构,能够保留在jvm缓存中。
- 当客户端获取到token后,会携带着token发动申请。
- 服务端接管到客户端申请后,首先会判断该token在redis中是否存在。如果存在,则实现进行业务解决,业务解决实现后,再删除token。如果不存在,代表以后申请是反复申请,间接向客户端返回对应标识。
业务执行机会
先执行业务再删除token
然而当初有一个问题,以后是先执行业务再删除token。
在高并发下,很有可能呈现第一次拜访时token存在,实现具体业务操作。但在还没有删除token时,客户端又携带token发动申请,此时,因为token还存在,第二次申请也会验证通过,执行具体业务操作。
对于这个问题的解决方案的思维就是并行变串行。会造成肯定性能损耗与吞吐量升高。
- 第一种计划:对于业务代码执行和删除token整体加线程锁。当后续线程再来拜访时,则阻塞排队。
- 第二种计划:借助redis单线程和incr是原子性的特点。当第一次获取token时,以token作为key,对其进行自增。而后将token进行返回,当客户端携带token拜访执行业务代码时,对于判断token是否存在不必删除,而是对其持续incr。如果incr后的返回值为2。则是一个非法申请容许执行,如果是其余值,则代表是非法申请,间接返回。
先删除token再执行业务
那如果先删除token再执行业务呢?其实也会存在问题,假如具体业务代码执行超时或失败,没有向客户端返回明确后果,那客户端就很有可能会进行重试,但此时之前的token曾经被删除了,则会被认为是反复申请,不再进行业务解决。
这种计划无需进行额定解决,一个token只能代表一次申请。一旦业务执行出现异常,则让客户端从新获取令牌,从新发动一次拜访即可。举荐应用先删除token计划
然而无论先删token还是后删token,都会有一个雷同的问题。每次业务申请都回产生一个额定的申请去获取token。然而,业务失败或超时,在生产环境下,一万个里最多也就十个左右会失败,那为了这十来个申请,让其余九千九百多个申请都产生额定申请,就有一些得失相当了。尽管redis性能好,然而这也是一种资源的节约。
基于业务实现
生成Token
批改token_service_order工程中OrderController,新增生成令牌办法genToken
@Autowiredprivate IdWorker idWorker;@Autowiredprivate 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拦截器
@Componentpublic 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启动类
@Beanpublic 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
@Beanpublic IdemptentInterceptor idemptentInterceptor() { return new IdemptentInterceptor();}@Overridepublic 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
测试执行,能够发现,只有一个申请是胜利的,其余全副被断定为反复申请。
本文由
传智教育博学谷狂野架构师
教研团队公布。如果本文对您有帮忙,欢送
关注
和点赞
;如果您有任何倡议也可留言评论
或私信
,您的反对是我保持创作的能源。转载请注明出处!