常见并发问题

重复添加购物车

背景

  • 购物车中同一商品只能有一条记录
  • 添加购物车时,如果商品已经存在,则在原来的数量上增加;如果不存在,则insert一条数据

错误逻辑
  

添加购物车流程图

时间线示意图

  如上图所示,添加购物车时,由于存在要更新,所以先select一下购物车,有毛病吗?没毛病!已经存在的话,就update,没有的话,就insert,有毛病吗?没毛病!


  那为啥购物车中会出现同一商品多条数据的情况呢?有毛病!

分析
  添加购物车时,每个请求都顺序执行的话,是没毛病的。但是,如果有两个请求同时到达的话,毛病就出现了。如下图所示:

两个事务并发执行时间线

只以购物车中不存在该商品为例。当有两个添加购物车请求差不多同时到达的时候,第一个事务在commit之前,第二个事务是读取不到第一个事务中新插入的数据的。那么,导致的结果就是,两个请求都成功insert了数据,最终导致购物车中同一商品出现多条数据。

解决办法
  购物车的这种情况,还是比较容易解决的。分析一下,业务的特点是什么?没有就insert,有就update。这正好可以使用mysql的一个特性(注意,只是mysql的特性,不是sql表情语法)来实现:

INSERT INTO table (a,b,c) VALUES (1,2,3) ON DUPLICATE KEY UPDATE c=c+1;

使用这种特性,首先要做的就是设置Unique key。在这里,根据业务分析,我们可以将userId和csuId设置成Unique key。这样就解决了问题。

重复下单

背景

  • 根据购物车下单
  • 下单完成后删除购物车中数据

错误逻辑

下单流程图
下单时间线示意图

如上图所示,下单时要首先查询购物车,看买了哪些东西,数量是多少。然后根据购物车创建订单数据,并insert到订单表。最后把购物车删除(注意,这里的删除不是delete操作,而是update valid字段为0,代表删除)。一切都是那么的顺理成章。但是就是这么顺理成章的逻辑,竟然出现了一份购物车数据生成多个订单的情况。本应该生成一个订单后,购物车就删除的,为啥还能生成订单,并且跟上一个一模一样?

分析

两个事务并发执行时间线

同购物车一样,当两个请求几乎同时到达的时候,会分别开启自己的事务。由于事务之间的隔离特性,第一个事务中虽然有删除购物车的操作,但是在commit之前,第二个事务都是察觉不到的。因此第二个事务依旧能够正常读取到购物车中的数据,从而能够往下执行生成订单。有些人可能发现,在删除购物车这个地方是有问题的。重复删除购物车可能导致第二个事务回滚,这样也不能重复下单。是的,这里的删除购物车只是简单的update一个valid字段为0,where条件也只是简单的cart主键,因此两个并发的事务都能‘删除’购物车成功,而重复‘删除’也不会回滚。这也是个问题。

解决办法
  删除购物车的SQL不能是简单的update cart set valid=0 where id=x。为啥?应如何呢?应该是update cart set valid=0 where id=x and valid=1。只是简单地多了一个where条件,语义大不相同。对于update cart set valid=0 where id=x,两个事务并发执行时,都会返回1 rows affected,但是对于update cart set valid=0 where id=x and valid=1这个语句,由于事务隔离,只有一个事务会返回1 rows affected。这样就保证了只有一个事务能够删除购物车。然后通过回滚机制,就可以保证不会重复下单。另外,使用delete彻底删除也可以的。

引申
  对于并发问题,有一种常见的方式是分布式锁
  例如,对于下单请求,针对同一个商家,我们可以把这个商家所有的下单操作都强制改成串行操作。在单机程序中,将并行改成串行,可以通过编程语言提供的加锁功能实现。而在分布式系统中,需要使用分布式锁来实现这个功能。

分布式锁

如上图所示,使用分布式锁锁住userId,这样同一个userId,在同一时间只能进行一个事务,其他并发事务会由于获得不了锁而返回失败。这样是能够保证不会重复下单。
  注意,为何获取锁失败以后是结束?重试可不可以?不是不可以,只是重试是有问题的,操作不当,就有可能出现错误结果。这个问题这里先不表,一会儿跟着重复退货章节一起讨论

重复退货

背景

  • 一个订单可以多次退货
  • 一个商品也可以多次退货
  • 退货商品不能超量

这里最关键的问题就是退货商品不能超量。比如商家只买了1件商品,如果退了2件,岂不是损失。
  另外,为了表述简单,这里只允许每次只退一种商品。例如,一个订单中买了A,B两件商品,每次退货只能退一种,即不能A,B一起退,只能要么退A,要么退B。

错误逻辑

版本一

经过上面的讨论,想必大家一眼就能看出来,上面这个流程是有毛病的。当然,同样的错误我也是不会再犯的,我可以犯其他的错误。比如下面的:

版本二

跟版本一区别在哪?很明显,这次增加了一个类似锁的东西,即(金光闪闪的)update语句。我们知道,在并发事务中,后启动的事务在遇到update时,一定会等待先启动的事务commit后再继续进行。这样,在加入update后,两个并发事务就被强制成串行执行了。

按照上面的逻辑,正确的执行逻辑应该如下所示:

我们知道,MySQL的事务隔离级别是通过MVCC实现的,这样可以不用加锁,大大提升性能。
  Request2的事务晚于Request1,那么Request1如果退货成功的话(假如商品只买了1件,Request1也成功退了1件),Request2会发现超退,从而终止退货的。嗯,一切都看起来那么无懈可击,完美!

然而,事实并不是这样的。现实是出现了只买了1件商品,确退了2次的情况。这到底发生了什么?

分析
  百思不得姐,转而在群里咨询其他人。有人说应该用cas,有人说应该用redlock,还有人说用select for update,总之都没有解释到为啥Request2读不到Request1新插入的数据。经过一番激烈地讨论,和多次歪楼再正楼(一有妹子出现,楼就歪,你懂得)的回合后,终于有一个人提出了一个建设性的解释:

并给出了自己的实验论证:

解释一下这个图。图中的红色阿拉伯数字表示执行顺序(记得set autocommit=OFF)。可以看出来,右边的事务先开启(数字1),然后左边的事务再开启(数字2)。然后在顺序执行3,4,5,6。很明显,6读到的数据是旧的,不是3操作的新值。mysql不会骗人,结果是没毛病的。但是这样解释是MVCC的造成的,是有毛病的。先抛开MVCC不说,如果6能读到3的结果,那问题就大了。这是啥,是不可重复读!mysql的默认隔离级别是RR,是不可能出现不可重复读的问题的,这是一个原则性问题。
  但是,退货时是insert操作,不是update,为啥也读不到呢?这是因为,mysql的隔离级别虽然是RR,但是,是解决了幻读问题的。RR不能解决幻读是SQL标准,但是MySQL实现也RR级别能够解决幻读问题。
  最终可知,犯上述错误,是由于自己对数据库事务隔离级别理解不够深入所致。从中得到的教训是:一般实现功能都是按照常人的思维考虑,先怎么着,然后再怎么着。这种思维方式容易跟忽视并发问题,也容易跟工具本身的特性有冲突。

解决方法
  首先,应该在订单详情表中增加一列,表示已经退货的数量,每次退货时,首先检查是否超退,如果超退,就返回失败。

update order_item set returned_avaliable_quantity=returned_avaliable_quantity-curr_quantity where returned_avaliable_quantity>curr_quantity;
注:returned_avaliable_quantity表示可退货量,初始值为购买量。curr_quantity为本次退货量。

这个方法的关键是,将超退判断从select查询判断改成update判断,update能够保证并发事务数据的一致性,这样就能确保不会超退。这个与库存超卖问题极其类似。其他的解释,可见章节库存超卖

引申
MySQL RR能否解决幻读讨论
  这个研究不多,大致的意思是MySQL的RR级别,通过MVCC + GAP锁实现了避免幻读。有兴趣的可做深入研究。
  
MySQL是如何通过MVCC实现RR的?
  在《High Performance MySQL》中,有这么一段对MVCC的简单规则介绍:

Let’s see how this applies to particular operations when the transaction isolation level is set to REPEATABLE READ:

SELECT
InnoDB must examine each row to ensure that it meets two criteria:

  1. InnoDB must find a version of the row that is at least as old as the transaction (i.e., its version must be less than or equal to the transaction’s version). This ensures that either the row existed before the transaction began, or the transaction created or altered the row.
  2. The row’s deletion version must be undefined or greater than the transaction’s version. This ensures that the row wasn’t deleted before the transaction began.
    Rows that pass both tests may be returned as the query’s result.

仅仅通过上述的这些简单规则,是不能实现RR的。这些规则也是引导自己入歧途的一个因素,即既然能select小于等于当前事务版本号的数据,为啥Request2不能读到Request1的数据呢。这样一个观念导致自己忽视了数据库隔离级别这样一个最根本的原则。
  那么问题来了,mysql是如果通过MVCC实现RR的?一定有很多我们不知道的东西在里面。这个问题,大家可参考如下几篇文章:

库存超卖

背景

  • 库存不能减成负数

库存超卖,一个很常见的问题,也是一个初学者很容易犯错的问题。

错误逻辑


  上面流程是初学者常见逻辑。这个逻辑很符合我们平常的思维习惯。不过,很明显,这个逻辑有可能会导致库存超卖。原因就是查询库存时select语句在并发时会有问题:由于事务隔离级别是RR,后面的事务在前面的事务提交以后,依旧不能读取到最新的数据。

解决方法
方法一:

update stock = stock - quantity where stock > quantity where product_id=xx;

这样做的好处是,将判断改成update语句,update能够保证数据的一致性。

方法二:

update table set stock=stock-quantity where and product_id=xx;
select stock from table where product_id=xx; //如果返回的stock数量小于0,则回滚

与方法一类似,不过需要手动回滚。不优雅

方法三:
  上面两种方法都是使用的乐观锁,不用加锁的。如果有人就想用select来判断库存,可以吗?可以!这就得使用悲观锁了。悲观锁能保证select到最新数据。

select stock from table where product_id=xx for update;
update table set stock=stock-quantity where and product_id=xx;

如有问题,请留言,我会及时回复,一起讨论问题。

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,290评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,399评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,021评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,034评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,412评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,651评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,902评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,605评论 0 199
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,339评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,586评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,076评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,400评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,060评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,083评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,851评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,685评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,595评论 2 270

推荐阅读更多精彩内容