【高并发】- 幂等性设计你知道多少?
1. 有关幂等性设计的场景 用户在电商平台购物,看到自己心仪的商品,于是将其加入购物车,之后进入购物车下单结算。这时,由于网络不通畅,用户在点击“提交订单”按钮时卡住了,用户以为没有提交成功,就又点击了一次“提交订单”按钮。最终,订单系统给该用户生成了两个订单,其实之前那个订单已经生成成功了。
这就是一个典型的幂等性问题。由于下单接口没有做好幂等性设计,所以导致用户进行了两次同样的下单操作,系统给用户创建了两个订单。
2. 什么是幂等性
所谓幂等性是指,用户对于同一个操作发起一次请求或者多次请求,得到的结果都是一样的,不会因为请求了多次而出现异常现象。
为了理解幂等性,下面来看一个非幂等的场景:
例如,在支付时,用户点击了两次“立即支付”按钮,发生了重复支付。最终,用户发现被扣了两次款,支付系统也生成了两条支付记录,这就是一个非幂等的场景。
2.1 需要幂等性的场景
幂等性主要用在重复请求上,有如下几种场景:
- 用户多次请求,比如重复点击页面上的按钮。
- 网络异常,由于网络原因导致在一定时间内未返回调用成功的信息,触发了框架层的重试机制。
- 页面回退后再次提交的动作。
- 程序上的重试机制:对于未及时响应的请求发起重试操作。
2.2 数据库操作的幂等性分析
数据库的上层业务操作分为CRUD(即新增、读取、更新、删除4个动作)。
-
新增(Create):如果自增主键唯一,则数据库会生成多条相同记录,不具备幂等性,如:
insert into(id, name, age, balance) values(1, 'test', 18, 100);
- 读取(Read):无论请求多少次,读取的结果都是一样的,所以读取是天然幂等的,如:
select name, age, balance from user where id = 1;
- 更新(Update):条件语句中带有计算的更新是非幂等的;反之,则是天然幂等的,如:
非幂等 update user set balance = balance + 100 where id = 1;
幂等 update user set balance = 200 where id = 1;
- 删除(DELETE):无论删除多少次,结果都是一样的,所以删除动作是天然幂等的,如:
delete from user where id = 1;
可以看出,读取和删除是天然幂等的,无论执行多少次请求,最终的结果都是一样的。从这里很容易联想到RESTful规范中的HTTP请求方法:POST(C)、GET(R)、PUT(U)、DELETE(D)。
- POST:相当于新增,不具备幂等性。
- GET:对资源的获取。在浏览器中通过地址进行访问,每次结果都是一样的,是天然幂等的。
- PUT;将一个资源替换成另一个资源。这是非计算型的更新,无论更新多少次,结果都是一样的,是天然幂等的。
- DELETE:同数据库的删除动作。无论删除多少次,结果都是一样的,是天然幂等的。
3. 如何避免重复提交 如果用户连续点击了两次“立即下单”按钮后,订单系统为用户生成了两个订单,这是不行的。这是重复请求所导致的。要防止重复提交订单(即请求多次和请求一次的最终效果是一样的),则需要订单系统在创建订单时具备幂等性。
3.1 利用全局唯一ID防止重复提交 在向数据库新增一条记录时,有时会出现错误信息“result in duplicate entry for key primary”,原因是插入了相同ID的信息。
利用数据库的主键唯一特性,可以解决重复提交的问题:对于相同Id的信息,数据库会抛出异常,这样新增数据的请求会失败。现在已经知道方案了,那么这个ID该如何和系统进行绑定呢?下面来看一下具体的落地流程。
(1)搭建一个生成全局唯一ID的服务。建议加入一些业务信息到该服务中,例如,在生成的订单ID中可以包含业务信息的订单元素(如“OD”20230201620005600001)。该全局唯一ID服务可以参考雪花算法SnowFlow进行搭建。 (2)在订单确定页面中,调用全局唯一ID服务生成订单号。 (3)在提交订单时带上订单号,请求到达订单系统的下单接口。
(4)将数据库订单表Id和订单号进行映射,将订单号作为订单表的ID。 (5)订单系统在创建订单信息时,订单号使用前端传过来的订单号,然后直接将该订单信息插入订单库中。 (6)如果订单写入成功,则是第一次提交,返回下单成功;如果报ID冲突信息,则是重复提交,在订单表中只保留之前的记录,不会写入相同的新记录。 (在报“订单重复提交”错误时,不要向客户端抛出错误信息,因为重复提交的订单不一定全部失败。如果给用户展示错误,则用户可能还会提交订单,这会使得用户体验不好。可以直接向用户展示下单成功)
3.2 利用“Token + Redis”机制防止重复提交 另一个常用的保证幂等性的方案:使用“Token Redis”机制防止重复提交。
(1)订单系统提供一个发放Token的接口。这个Token是一个防重令牌,即一串唯一字符串(可以使用UUID算法生成)。
(2)在“订单确认页”中调用获取Token的接口,该接口向订单确认页返回Token,同时将此Token写入Redis缓存中,并依据实际业务对其设置一定的有效期。
(3)用户在“订单确认页”中点击“提交订单”按钮时,将第(2)步获取的Token以参数或者请求头的形式封装进订单信息中,然后请求订单系统的下单接口。
(4)下单接口在收到提交下单的请求后,首先判断在Redis中是否存在当前传入的Token:
- 如果存在,则代表这是第一次请求,会删除这个Token,继续创建订单的其他业务。
- 如果不存在,则代表这不是第一次请求,而是重复的请求,会终止后面的业务操作。
所以,在应对并发修改场景下,对于Token的获取、比对和删除,需要使用原子操作。在Redis中可以用Lua脚本进行原子操作。
利用数据库的主键唯一特性和Token机制来避免重复提交,是一种比较常用的接口幂等性方案。对于重复提交的场景,需要依据业务进行分析,分析当前场景是否具备幂等性,如果不具有幂等性,则需要进行幂等性设计。
(幂等性设计方案远不止以上两种,例如,可以利用数据库的悲观锁和乐观锁,或者分布式场景下的分布式锁等,来防止重复提交)
4 如何避免更新中的ABA问题 前面讲到数据库更新时,如果是类似计算型的更新,则其是非幂等的;如果不是计算型的更新,则其实天然幂等的。因为,无论更新多少次,最终结果都是一样的。这种情况在大部分场景都是没有问题的,但是在高并发场景下,就有可能是非幂等的,从而造成数据的不一致。 场景: 用户在和商家讨价还价后,提交订单等待商家修改价格,商家在商品页上将订单价格从原来的100元,修改为80元。改完后,商家发现改错了,于是返回重新修改为90元,订单系统对于上架这两次的修改都执行成功了。 但是,由于网络异常原因,订单系统未及时将“前一次修改为80元成功的结果”返回给订单页,从而触发了重试逻辑,所以,商品价格在被修改为90元之后,又被修改为80元,即最终成交价格变成了80元,这就造成了数据的不一致,对商家也是一个损失。这是一个经典的ABA问题。 解决ABA问题一个常用的方案是:使用数据库的乐观锁来解决。即在订单系统的订单表中增加一个字段版本号(version),在每次更新时都判断两个版本号是否相等,如果不相等则不更新。具体流程如下: (1)在订单页获取修改订单时,同时将订单版本号作为订单属性一起返回给前端页面。 (2)在前端订单页修改订单时,将订单关键信息,如订单ID(orderId)、修改的价格(newPrice)及获取的版本号(version)等,一起传到订单修改接口中。 (3)订单系统的订单修改接口,在收到价格修改的请求后,首先判断当前传过来的版本号和数据库当前订单的版本号是否一致。如果不一致,则拒绝当前更新;如果一致,则更新当前数据,同时版本号加1。 “比较版本号”、“更新数据”、“对版本号加1”这几个动作都必须保证原子性,即要在一个事务内,SQL语句如下:
通过版本号可以保证从“订单在订单页被查询”到“成功更新这条数据”这期间内没有其他人能够修改这条数据。只要有人修改了这条数据,版本号就会增加。版本号增加了,当前更新拿到的就是旧版本号,会更新失败,需要重新发起查询请求以获取最新的版本号。update order set price = 80, version = version + 1
where order_id = 10001 and version = 1
加上版本号控制后,再来看一下是否保证了“订单并发修改”的数据一致性。
如上图可知,商家的并发修改解决了ABA问题:
- 当商家修改价格为80元时,携带了版本号version = 0,订单系统匹配发现刚传的版本号与数据库中的版本号是一致的,所以更新价格为80元成功,同时将版本号加1.
- 当商家又将价格修改为90元时,刚传的version = 0,但当前订单的version = 1,所以当前价格更新失败。
- 商家看到更新失败的原因,就可以刷新当前页面,重新获取最新的版本号(version = 1),进行订单修改。
本章节总结了高并发系统中关于幂等性设计的场景及解决方案,小伙伴们可以根据自身业务进行取舍。设计出符合当前公司实际的幂等性设计。
点个在看你最好看
评论