架构与思维:高并发下幂等性解决方案

1 背景

我们的云办公系统有一个会议预定模块,每个月最后一个工作日的下午三点,会启动对下个月会议室的可用预定。
公司的会议室大约200个,但是需求量远不止于此,所以会形成会议室抢订的场面(抢订大军为行政助理、人事助理、开发经理、产品运营等对会议室有刚性需求的人)。
程序团队,经常会接到投诉,A同学和B同学抢了同一个会议室,前端页面显示为两个占位图片,从数据库看,是插入了两条同一个会议位置的数据,这两条数据的发起人员分别是A和B。
这就牵扯出一个数学与计算机学概念:幂等
 
在计算机系统操作中,有很多种行为,需要保证无论执行多少次,都应该产生一样的效果或返回一样的结果。 
比如:
1、前端重复点击提交表单选中的数据,在后台应该只能有一个数据录入到数据库;
2、发送同一个消息,也应该只发一次,用户不会收到多条一样的数据;
3、创建业务订单,一次业务请求只能创建一个,如果程序没有保证幂等,创建出多条订单数据,就混乱了。
4、在高并发情况下,对于单一的数据,不可以多次使用,比如一张确定位置的电影票,不会被多次预订成功。同理的,同一时间的一个会议室信息,不会被多次预订。
etc.很多重要的场景都需要幂等的特性来支持。

2 幂等性概念

幂等(idempotent)是一个数学与计算机学概念,常见于抽象代数中。

在我们的开发过程中,保证幂等性就是保证你的程序的无论执行多少次,影响均与第一次执行的影响是一致的,产生的结果也是一样的。

而幂等函数(幂等方法),是指使用相同的参数结构重复执行,产生相同的结果的函数,重复执行幂等函数不会影响系统的状态或者造成改变。

例如,"getUserName(String uCode)" 和 "delUser(String uCode)" 函数就是典型的幂等函数,而更复杂的幂等保证是类似 高并发场景下的订单号(流水号)或者 秒杀场景下的唯一有效数据 等。

所以,幂等就是一个操作,不论执行多少次,产生的效果和返回的结果都是一样的。

3 幂等性问题的常见解决方案 

3.1 查询操作和删除操作

查询一次和查询多次,在数据不变的情况下,查询结果是一样的,所以严格来说,select是天然的幂等操作
删除也是一样的,对于单条数据来说,删除一次和删除多次都是把数据删除,影响和结果都是一样(当然,程序上 的执行的返回结果可能会不一样,比如操作数据库的时候,删除的数据不存在,返回0,正常删除成功,返回1) 。 
1 -- 用户库查询某个身份证号的用户名
2 select user_name from t_user where id_no ='xxx';
3 
4 -- 用户库删除某个身份证号的用户
5 delete from t_user where id_no ='xxx'

3.2 使用唯一索引 或者唯一组合索引

避免插入同样信息的脏数据。
比如:中秋节到了,淘宝上线某款限量版的月饼,每个用户都只能购买一盒月饼,如何防止用户被创建多条月饼订单数据,可以给月饼销售表中的用户ID加唯一索引(不允许被索引的数据列包含重复的值),
保证一个用户只能创建成功一条月饼订单记录。
1 CREATE UNIQUE INDEX uni_user_userid ON t_user(userid)
唯一索引或唯一组合索引来防止新增数据出现脏数据(当表存在唯一索引,并发执行时,先进入的执行成功,后进入的会执行失败,说明该数据已经存在了,返回结果即可)。如下图所示。
 
 
 
回到我们上面的哪个会议室预订,也可以是一样的方式,可以用会议室编号(该编号具有唯一标识)作为唯一索引,但是他的实际情况更复杂。 

3.3 token机制

防止页面重复提交而导致的数据重复

业务现象: 页面的数据只能被提交一次,或者提交多次的结果是一致的,不会产生多余的脏数据

产生的原因: 由于系统卡顿导致的重复点击或网络重发,还有就是nginx重发等情况,导致的数据被重复提交;

解决方法: 

  • 集群环境采用token加redis(redis单线程的,处理需要排队);
  • 单JVM环境:采用token加redis或token加jvm内存。

处理步骤:

  1. 数据提交前要向服务的申请token,token放到redis或jvm内存,token需要设置有效时间,一般我们一个请求从request到respond时间是很短的,所以有效时间可以设置短一点;
  2. 提交后后台校验token,同时删除token,返回执行结果。token特点:一次有效性,用完即删,可以限流执行。
 
流程如下,注意:redis要用删除操作来判断token,删除成功代表token校验通过;
 
  

3.4 悲观锁

获取数据的时候加锁获取。 select * from t_name where id='xxx' for update; 

注意:这边的id字段一定是主键或者唯一索引,不然会导致锁表。悲观锁使用时一般会配合事务一起使用,数据锁定时间可能会很长,根据实际情况选用。  

3.5 乐观锁

乐观锁只是在更新数据那一刻锁表,其他时间不锁表,所以相对于悲观锁,效率更高,适用于多读少写的类型,并发大的情况。

乐观锁的实现方式多种多样,可以通过version或者其他状态条件:

1. 通过版本号实现  update t_name set name=#{name},version=version+1 where version=#{version}; 

2. 通过条件限制  update t_name set avai_amount=avai_amount-#subAmount# where avai_amount-#subAmount# >= 0  

使用版本号的方式执行过程如下图:

  
 
这边需要注意:乐观锁的更新操作,如果加上主键或者唯一索引来作为条件, 更新时锁的是行,否则更新时会锁表,性能效率差很多。所以上面两个sql改成下面两个会好很多。 
1 update t_name set name=#name#,version=version+1 where id=#id# and version=#version#;
2 update t_name set avai_amount=avai_amount-#subAmount# where id=#id# and avai_amount-#subAmount# >= 0;  

 3.6 分布式锁

如果是分布是系统,构建全局唯一索引比较困难,不同的链路业务可能分布在不同的数据库表中,所以唯一性的字段没法确定,这时候可以引入分布式锁,通过第三方的系统(redis或zookeeper),

业务系统插入数据或者更新数据,获取分布式锁,然后做操作,完成业务操作之后,释放锁,这样其实是把多线程并发的锁的思路,引入多多个系统,也就是分布式系统中得解决思路

关键点:某个长流程处理过程要求不能并发执行,可以在流程执行之前根据某个标志(用户ID+后缀等)获取分布式锁,其他流程执行时获取锁就会失败,也就是同一时间该流程只能有一个能执行成功,执行完成后,释放分布式锁(分布式锁要第三方系统提供)。 

后面有一篇专门分析分布式锁方案的文章,还在草稿箱,待整理。

3.7  select + insert

并发不高的后台系统,或者一些简单的执行任务,为了支持幂等,支持重复执行,简单的处理方法是,先查询下一些关键数据,判断是否已经执行过,在进行业务处理,就可以了。

但是同样有问题,核心高并发流程不便使用这种方法。因为他本质上还是两个步骤,中间还有执行间隙的,在超高并发的情况还是会造成数据不一致的情况,这对于核心业务就是灾难了。 

3.8 状态机幂等

在设计单据相关的业务,或者是任务相关的业务,肯定会涉及到状态机(状态变更图),就是业务单据上面有个状态,状态在不同的情况下会发生变更,一般情况下存在有限状态机,
这时候,如果状态机已经处于下一个状态,这时候来了一个上一个状态的变更,理论上是不能够变更的,这样的话,保证了有限状态机的幂等。
注意:订单等单据类业务,存在很长的状态流转,一定要深刻理解状态机,对业务系统设计能力提高有很大帮助  

3.9 保证Api接口的幂等性

如银联提供的付款接口:需要接入商户提交付款请求时附带:source来源,seq序列号 ,source+seq在数据库里面做唯一索引,防止多次付款(并发时,只能处理一个请求) 。

关键点:核心业务功能,对外提供接口为了支持幂等调用,接口有两个字段必须传,一个是来源source,一个是来源方序列号seq,这个两个字段在提供方系统里面做联合唯一索引,这样当第三方调用时,

先在本方系统里面查询一下,是否已经处理过,返回相应处理结果;没有处理过,进行相应处理,返回结果。为了幂等友好,最好先查询一下,是否处理过该笔业务,不查询直接插入业务系统,会报错,而实际是已经处理过了。  

4 会议室的解决方案

将每天的会议预定按照半个小时1位做48位占用位符预算,建立缓存机制,进行高效率的占位判断,并反写到预定表;启动额外调度服务做最终的预定持久化;

采用唯一联合索引保障高并发下的幂等性策略。将会议室ID、时间段、日期,建立唯一组合索引,防止新增脏数据,保证不会有两条一样的会议室预定记录插入 

1 CREATE UNIQUE CLUSTERED INDEX [ClusteredIndex_A9_MeetingReser] ON A9_MeetingReser
2 (
3 [timespan] ASC,
4 [roomid] ASC,
5 [sdate] ASC
6 )WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, IGNORE_DUP_KEY = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY] 

执行会议预订的事务脚本,如下,当数据库中存在一样的会议室信息时,会返回错误(被占用)的状态值。

1  BEGIN TRAN T_Add;  
2  DECLARE @code INT; DECLARE @occupyMeeing TABLE ( sMeetCode INT ); 
3  DECLARE @resutlTable TABLE ( lType TINYINT,/*返回类型0为失败类型,1为成功类型*/ resutlValue NVARCHAR(60)/*返回的信息*/ ); 
4  -- Todo 业务逻辑 写入数据库操作,即会议号和占用的时间段标识为联合索引,不可重复插入,重复插入报错 
5  IF @@ERROR!=0 goto w_err;  
6  COMMIT TRAN T_Add ; 
7  goto w_end   w_err:  
8  ROLLBACK TRAN T_Add ;  
9  w_end:  SELECT * FROM @resutlTable

原来从预定到判断占用到写库会耗时0.5~1s,优化后整个流程执行性能提升到50ms左右,避免了会议室预定冲突的情况。

结果:根据会议室预定记录的统计,优化发布之后再未发生过预定冲突的问题。免除了会议管理员与预定人员沟通协调会议室的成本,解决了长期困扰他们的问题。 

5 总结

幂等本质上与系统是否分布式、高并发,业务执行频率高不高,没有直接的关系。关键是程序的操作过程是不是幂等的。

典型的幂等操作就是:把某个变量设置为1这种行为,不管执行多少次都是幂等的,你在进行互联网支付的时候,即使系统卡顿,你提交多次,也只支付一次。

要做到幂等性,从接口设计上来说不设计任何非幂等的操作即可。特别在类似支付宝,银行,互联网金融公司等涉及的网上资金系统,既要高效,数据也要准确,不能出现多扣款,多打款,产生金钱交易不一致等问题。

 
posted @ 2021-12-31 15:01  Hello-Brand  阅读(3474)  评论(5编辑  收藏  举报