架构与思维:高并发下幂等性解决方案
1 背景
2 幂等性概念
幂等(idempotent)是一个数学与计算机学概念,常见于抽象代数中。
在我们的开发过程中,保证幂等性就是保证你的程序的无论执行多少次,影响均与第一次执行的影响是一致的,产生的结果也是一样的。
而幂等函数(幂等方法),是指使用相同的参数结构重复执行,产生相同的结果的函数,重复执行幂等函数不会影响系统的状态或者造成改变。
例如,"getUserName(String uCode)" 和 "delUser(String uCode)" 函数就是典型的幂等函数,而更复杂的幂等保证是类似 高并发场景下的订单号(流水号)或者 秒杀场景下的唯一有效数据 等。
所以,幂等就是一个操作,不论执行多少次,产生的效果和返回的结果都是一样的。
3 幂等性问题的常见解决方案
3.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 使用唯一索引 或者唯一组合索引
1 CREATE UNIQUE INDEX uni_user_userid ON t_user(userid);
3.3 token机制
防止页面重复提交而导致的数据重复
业务现象: 页面的数据只能被提交一次,或者提交多次的结果是一致的,不会产生多余的脏数据。
产生的原因: 由于系统卡顿导致的重复点击或网络重发,还有就是nginx重发等情况,导致的数据被重复提交;
解决方法:
- 集群环境采用token加redis(redis单线程的,处理需要排队);
- 单JVM环境:采用token加redis或token加jvm内存。
处理步骤:
- 数据提交前要向服务的申请token,token放到redis或jvm内存,token需要设置有效时间,一般我们一个请求从request到respond时间是很短的,所以有效时间可以设置短一点;
- 提交后后台校验token,同时删除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
使用版本号的方式执行过程如下图:
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这种行为,不管执行多少次都是幂等的,你在进行互联网支付的时候,即使系统卡顿,你提交多次,也只支付一次。
要做到幂等性,从接口设计上来说不设计任何非幂等的操作即可。特别在类似支付宝,银行,互联网金融公司等涉及的网上资金系统,既要高效,数据也要准确,不能出现多扣款,多打款,产生金钱交易不一致等问题。