并发问题带来的后果

最后更新于:2022-04-02 04:29:04

## 并发问题带来的后果 ![](http://cdn.aipin100.cn/18-5-5/47888058.jpg) > 任何程序,哪怕再短小的程序,运行过程都是一个运行时间段,称为运行段,绝不是只运行一下的。 >[danger] 注意到A、B程序运行段有重合部分,在时间上重合就说明有并发争夺资源的可能,即有可能出现并发问题(我们把因并发情况导致的程序未能按照预期运行或未得到预期结果的情况,称为并发问题)。 **两个进程同时请求一个资源、修改,总会有一个持有旧的数据,这是必然的**,遇到这样的实际问题,就要用“解决实际问题的解决办法”来解决?(这句话不是废话吗?) A、B同时读取数据,并修改 在B修改时,A已经提前修改了,所以B修改数据时,持有的数据就变成旧的了,但是B并不知道,在他之前发生了什么,这带来的后果根据不同的应用场景不同,比如: 我们购物时,下单的那一刻查询到的价格为3元,那么订单的应付的钱也就是3,但就是在那一刻,商家把价格上调了,变为4,于是就出现这样的问题了,商家看到明明在第四秒钟时价格被变为4了,而订单创建时间也是第四秒钟,为什么订单的价格是3呢? 问题就出在,下单时,查到的价格的确为3,但是就在准备写入订单时价格变为4了,这一切下单的程序并不知道,也就是下单的进程持有的是旧数据,用旧数据下了订单。 实际上,我们永远不知道下一刻会发生什么,不可能保证此刻持有的数据是最新的,除非用锁,我们获取这个数据时就加上排它锁,这样才能保证我们此刻持有的数据是最新的,因为别人不能修改了。 这个价格问题其实不算问题,更严重的问题是涉及到金钱的问题: 比如一个账户两个地点登录,取钱,收钱查询余额满不满足取钱,然后完成取钱,扣钱。 如果A、B同时操作,会出现都满足取钱条件,然后都扣钱,这样余额就会变成负数了。 所以这个问题才是真正严重的问题,不能允许这种情况出现,所以这个过程很重要,持有旧数据进行操作造成的后果很严重,所以只能使用锁,查询的时候就阻塞其他进程在进行查询,这样就不会出现两个进程同时满足取钱条件了。 > 并发问题其实就是一种“[线程安全](http://mp.weixin.qq.com/s/zDbcV_vJeBOnAYxK0WEJQQ)”问题。 * * * * * ### 竞态资源与锁 并发问题有很多情况,有时候防止多进程对竞态资源的获取出现问题,比如下单减库存问题,这是由于旧数据造成的后果问题,可以使用乐观锁和悲观群来解决,锁可以利用数据库或者文件实现,但是要区分竞态资源和锁是谁,比如,这个例子,大批量生成二维码,为了保证二维码是连续的,必须对生成过程加锁,但是过程怎么锁呢,那么我们根据一个key来锁文件,以锁文件来约定表示锁过程,这样就安全了。但要注意,如果其它程序,其它文件控制器中,不遵守这个约定(要走过程必须先获得锁,而同时只能有一个进程获得锁,其它进程再获取锁都会被阻塞。),不用锁,直接走过程那就没用了,所以处理竞态资源一定要遵守约定,比如其它和库存有关的并发业务,不管是在哪里的代码,哪里的业务,也要使用事物锁,必须遵守这个相同的约定,只要有一个没有遵守,那么还是会出现并发问题。 重要的是要理解清楚哪部分是竞态资源,另外怎么安全高效的实现锁,确保竞态资源面对并发时是安全的。 所说的安全是指,业务是安全的,业务逻辑是正常的,是预期的。 竞态资源和锁可以是同一个对象,比如减库存问题可以直接锁商品行,用数据库的行锁,但也不全是这样,比如批量生成二维码的例子。生成二维码的过程是竞态资源,过程是个抽象的。要保证的是同一时刻最多只能有一个这个过程存在,这样才能保证业务逻辑正确。而锁是利用文件锁(当然还有很多方式实现锁)。 **竞态资源:被多个使用者竞争(抢夺)使用的资源。** >[danger] 有时候要保证并发安全,就需要考虑对竞态资源加锁。 **上面所说的约定即相同的数据访问规则** > 只有将所有线程都设计成遵守相同的数据访问规则,互斥机制才能正常工作。 **操作系统并不会为我们做数据访问的串行化。** 如果允许其中的某个线程在没有得到锁的情况下也可以访问共享资源,那么即使其他的线程在使用共享资源前都申请锁,也还是会出现数据不一致的问题。(UNIX环境高级编程第三版 P-380) * * * * * **告诉我你能承受的,最坏的后果是什么?** >[danger] **只要持有旧数据进行操作的过程都是不符合预期的,都是不合法的,必然也一定会带来问题的,只是根据带来问题的不同,我们选择处理方式也不同**,比如取钱,减余额操作就是很重要的,所以不能让这种问题在这里发生,而对于如果状态怎么怎么样,就可以怎么怎么样,这样的操作要视情况和出问题所带来的的后果而定,如果不是特别重要的业务,就不需要加锁,如果比较重要的也还是需要加锁,这样逻辑才严谨。总之像减余额这种是级别最高的,必须加锁,不然连钱都守不住,还能干别的? **“这样的操作要视情况和出问题所带来的的后果而定”,这句话什么意思呢?** 比如:如果状态允许下载就能下载,当下载的那一刻,管理员同时更新为不可下载,由于下载操作检测状态使用的是“一致性非锁定读”那么并发时就会出现理论上的问题:检测时是允许的,可是下一刻变成不允许的,但是检测已经过了,这时下载程序继续向下执行持有的就是旧数据了,那么此时就会出现还能下载的BUG,这就是后果,如果你能允许这个后果,你默许的是检测那一刻是允许就表示是允许的,那么就可以不加锁,如果你非要执拗与下载的那一刻是什么样的状态,不能容忍这样的情况出现就需要加锁了。 >[danger] 这要看你对那个点的理解要求是什么,不同情况对那个点的要求是不同的,下单肯定要求是下单的那一刻的,下载这个就以检测的那一刻为准就好了,后面发生的旧数据,就不管了,我只认可检测那一刻的数据,以它为准就好了。 减余额问题,出现的后果就不用说了,钱都保护不了,还能干什么。 可见旧数据无处不在,只要不加锁,可以说每一步操作都会持有旧数据。具体怎么做,加不加锁,这取决于你的程序能不能容忍出现旧数据后所带来的后果,这样后果你必须要预料到(提前考虑到了,并且有应对的措施,心中有数)。 所以减余额这种操作,如果你可以选择不加锁,能接受它所带来的的后果的话,那我就不说什么了,土豪,我们做朋友吧。 > 还有一种问题是“更新丢失”,但是这个严格来说不是并发问题,不可能把用户编辑文档的整个时间/过程都锁上吧,但其实有时候,还真是这样做的。一个资源只允许一个客户端打开,比如看云就是的,一个文档打开编辑页面,另一个窗口再打开编辑,就会提示“账号已在其它地方登陆”,这样就保证了同时只能让一个客户端进入编辑页面,这样就不会出现更新丢失的问题了。 * * * * * **持有旧数据的情况随处随时都在发生**,根据持有旧数据后要做的操作是否有危险就可以确定 持有旧数据进行操作所带来后果是不是会让程序变得和预期的执行不一样,也就是产生BUG,**其实很多时候的持有就数据操作并不危险,所带来后果并没有任何问题,并不会让影响程序逻辑**,比如:为文章添加一个标签,先要判断标签是否存在,我没有用锁,但是如果你突然删除了标签,这就造成我提交后,文章有一个空的标签,这就是后果,可是这个后果不会带来什么影响啊,文章标签显示时会做处理的。而如果我们强行的使用锁,那可想而知,这样的地方很多,几乎每一个这样的地方都用锁,可以想象这样会牺牲掉很多性能的,所以需要在数据强一致性和性能之间找一个平衡点,但是不管怎么样,安全是第一的,而这个问题中显然不会出现安全问题,不是转账的操作,也不是很重要的操作,我们能接受这个后果,不认为它会对我们的程序基本结构和逻辑产生影响,所以我们当然更加倾向于性能了。总之安全第一,不管什么时候,分析完各种情况下所出现的后果,你就知道该怎么做了。 * * * * * **什么是旧数据** ~~~ 例子1: A … B A对B所说数据是1 B好的 ####### 例子2: A 数据是1哦 …c 小c来了,嘿嘿,没人发现我来了吧,我现在把数据改为2了 B(嗯,A跟我说的数据是1) 结果:卧槽,这不对啊 A:我没错啊 B:我也没错啊 那么请问谁错了? 答:例子1没问题,例子2问题出在B持有了旧数据后还进行了操作。小c在中间把数据改为了2,而B还拿着A给它的数据1,B根本不知道最新的数据已经是2了,他还拿着1,就是旧数据。 ~~~ >[danger] mysql查询操作默认不加排他锁(加共享锁,并行非阻塞),更新操作默认会加排他锁(串行阻塞的),这就是在操作产生“缝隙”的原因,而乐观锁查询时虽然没加排他锁,但是它更新时利用更新操作排他锁的特性,把where查询操作和update更新操作合在一条语句里面了,所以就不会出现“缝隙”了。 (如果更新影响的条目为0,也就是说发现旧数据了。乐观锁会带来“重试”——即用户下单时会提示没有库存了(特点:返回时间短,不阻塞,可以让用户重新试着再次提交,重试因此得名);而悲观锁,针对同一商品的抢购秒杀时,用户会明显的感觉到提交订单时有点慢,因为加的是悲观锁。特点:阻塞,耗时,返回结果靠谱,不重试; 两种锁的相同点,并发的量越大,重试的几率越大,阻塞的时间也越长。) 补充:乐观锁的where根据实际情况会有很多种查询方式,这个看你对数据要求的严格级别了。比如下单时,针对库存`where inventory > $num` 这样就可以了,而不需要这样 `where inventory = $inventory_old`,有的要求严格甚至的这样 `where version = $version_old` ,当然具体怎么用还需要根据实际实际情况去考量的。 **其实就是这个控制了乐观锁的“锁粒度”了,where越宽松,锁粒度越大,重试的几率越小,where越严格,锁的粒度越小,重试的几率越大,即锁粒度和重试几率成反比。**(终于明白了我以前不理解的概念) **锁粒度与重试记录(几率)成反比,与并发能力成正比。(仅限于乐观锁)** 所以在使用乐观锁的时候要尽可能的使用更加宽松的where,使所粒度更大,这样重试的几率就会小些,从而提高程序的并发性。(不然你老让用户重试好啊) **悲观锁,锁粒度越小,并非能力越大。锁粒度和并发成反比。(仅限悲观锁)** [Mysql乐观锁悲观锁行锁表锁是从哪几个方面来分类的?比如说表锁也可是乐观锁,也可以是悲观锁吗?](https://segmentfault.com/q/1010000003917591) * * * * * **思考:** 看到了乐观锁实现的原理,发现乐观锁的本质其实不是锁。哈哈,可以叫乐观锁为假锁,悲观锁为真锁。 其实悲观锁的内部实现我们真的了解吗? 乐观锁,悲观锁,文件锁。进程锁,内存锁,自旋锁,软锁,硬锁,cas,互斥量,读写锁,……,本质是什么,具体内部究竟是怎么实现的呢?或许其实根本就是乐观锁吧,我们的程序是运行在操作系统上的,内部实现应该是操作系统提供的实现。 参考:[内存锁与内存事务 - 刘小兵2014](https://my.oschina.net/digerl/blog/34001#tt_daymode=1) > 锁有更复杂的实现如更新锁,意向锁,乐观锁,悲观锁,轻量级锁,偏向锁,自旋锁等等,都是针对不同的需求特征或事务等级定制的锁。事实上,这里面的很多只是使用了锁的名字,所以其实并不是锁,因为它们没有起到排它的作用如自旋锁,轻量级锁,偏向锁等等其实都不是锁。特别是乐观锁,却是通过不锁来达到锁的效果。 不管是锁还是什么,我们应用它的目的都是为了,让我们的程序在任何时候,业务逻辑不会出错,跟预期一致,安全、稳定、可靠。这三点是对一个合格软件的基本要求。 * * * * * **你当前所查看的数据永远都不可能一定是最新的,除非你在查看的同时就把它锁住。** * * * * * **乐观与悲观** >[info] 不行那就再试一次哈,乐观点嘛^_^ >[danger] :( 好慢啊,我被人阻塞者,一直在等待,郁闷中……,悲哀啊! * * * * * **旧数据怎么来的?** 持有旧数据进行操作会带来后果的根本原因就是因为,我们每一种完整的操作(添加文章,为文章添加标签等等)都是有一些单个操作组成的,而这些操作执行是是单独的,比如一条一条的sql语句,这中间的过程是有缝隙的,那么问题就出在这缝隙里面,处于缝隙的过程中其他的操作就可能会随时插进来(我们永远不能预测谁会什么时候突然插进来),就出现旧数据了,而出现旧数据之后会带来问题,是因为,我们后面的操作可能依赖于前面的操作的反馈结果和数据,本来没人插进缝隙产生旧数据,那么程序的执行就是我们预期的那样,现在缝隙被人插入了,有旧数据了,而我们后面的操作却对此浑然不觉,闷头的继续操作,那么显然就会出现和我们预期不一样的结果,也就是BUG了,这个后果是可以提前考虑分析出来的,具体应该做上面已经说清楚了。 * * * * * **前台:我们永远不敢说我此刻所查看的数据是最新的** 只要是对数据有更改的操作都需要考虑并发问题,一般是`POST`类的请求,简单的`GET`通常不会对数据进行更新的操作,所以一般没有并发的问题,只是简单的取出数据而已,用的是**一致性非锁定读**,只是查看数据而已,不会对数据有确切的实时性要求,因为理论上前台查看数据永远不可能确保你此刻所查看的是最新的数据,**也就是前台对旧数据并不那么在意**,它只是显示而已,并不会造成什么安全性的问题,但是更新的操作就不同了,所以请注意这点,**注意所有的`POST`操作,所有的可能对数据进行更改的操作,谨慎对待一切会对数据进行更改的操作。** * * * * * ### 薛定谔的猫 **实际上,这个问题就是[薛定谔的猫](https://www.zhihu.com/question/57096040/answer/153384751)** > ……你个东西你不去管它,它就在那里,若你研究它,研究结果就会受到你自身的影响。…… > 数据就在那里,它的存在与你无关,不论你是否去观察它,但是当你一旦去观察它的时候,结果又会受你自身的影响了,所以理论上,你永远无法得到它真实的样子,除非你不去观察它,但你不观察它你就不知道它此时处于什么状态。 对于万物论来说,每时每刻都有事件发生,你觉得什么时候发生,那是相对观察着的,即使没有观察者,事件也在发生,但是这样我们就不知道了,所以通常我们所说的发生,其实都是站在观察者的角度的。 * * * * * **思考:** >[danger] 实际上时间是一个相对概念,秒、微妙,还可以再进行细化,理论上存在同一时刻,但是实际上却不能够证明这一点,因为理论上你知道无限细分最终也是会在一个点上的,而实际上你又无法去实际的证明它。这是一个无法被证明的理论。 [怎么去证明两个是事件是在同一时刻发生的?](https://segmentfault.com/q/1010000009908203) * * * * * [我是一个线程(修订版)](http://mp.weixin.qq.com/s/-BMCUuIWYE3O_oC3ZNNJRg) [我是目录 - 2017-07-11 刘欣 码农翻身](https://mp.weixin.qq.com/s/nCx7Jb5WRXGzkpsuth6LAw) * * * * * ### 相关阅读 [为什么说乐观锁是安全的的](https://www.kancloud.cn/xiak/php-node/348580) * * * * * **其他** 这是微信支付文档 支付结果通知 中的一段摘抄 ![](https://docs.gechiui.com/gc-content/uploads/sites/kancloud/ff2784fba7f646bbf8f7a3085032c019_964x317.png) 参考:[函数重入问题 - CSDN博客](https://blog.csdn.net/gj19890923/article/details/9017809) * * * * * ### 如果不能完全避免错误,那就努力把出错的概率降到最低。 ~~~ `switch` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '应用开关:1-开,0-关。应用升级、安装、维护时需要暂时关闭,使其不能访问,否则会出现意外(即使这样也还是不能完全避免极端时候的并发问题,不过还是将故障降到最低概率了)', 来自家校 s_app表 ~~~ * * * * * ### 安全:陷阱和意外无处不在 >[danger] 系统不断向下游发送sql,这就是也业务逻辑的体现,但对于下游数据库系统来说,它并不知道,它并不关心你所谓的业务逻辑,它只知道接受sql执行。**这些sql交错着**,最终顺序被下游数据库执行。但是 `一个业务逻辑` ,**包含的多条sql要保证顺序执行,中间不能被其它sql插进来,否则就会有并发问题,因为它们是一组的**,业务就不能按照预期执行,要保证每个业务的sql为一个原子,独立的整体单元,而不是分散的单条sql,这就要引入事物和锁了。 >[danger] 业务逻辑要正确,要稳定可靠,就必须保证它是严格按照我们预期的执行的,不会出现差错,不能够出现意外,所以你要谨慎小心的对待,你的每一步都有可能踩到陷阱里面去。所以每一步你都要认真的思考。 **为了安全,任何时候都得考虑并发问题,都要考虑并发问题带来的每一种后果,并做好容错。** 任何时候,只要是大于一条sql的操作,都必须使用事务控制,因为你不可能保证第二条语句一定会成功,使用事务控制,保证数据的一致性/完整性,直接回滚是最简单的。 > 想想没有事务的年代(MYISAM),如果第二条出错,我们还得判断,手动处理失败留下的后遗症问题,比如onethink里面,文章新增sql失败的话要手动删除上面一条sql的插入,但是谁又能保证修复语句又一定能成功呢,如果也失败了呢,这陷入了一个无限循环去补救,用一个错误去弥补上一个错误的泥潭(深渊)里去了,所以事务是你唯一的选择。 >[danger] 根据“墨菲定律”——“凡事只要有可能出错,那就一定会出错。所以凡是有可能失败的,就一定会出现失败的情况,凡是可能出现并发问题的,就一定会出现并发问题,所以每一行代码都要严谨,并为可能出现问题的部分做好异常处理,以保证代码在每个地方都周到严密,不会在未来出现什么乱子。 ![](http://cdn.aipin100.cn/18-4-2/77693588.jpg) https://github.com/top-think/framework/blob/5.1/library/think/cache/driver/File.php 所有的 文件/目录操作 都可能出现并发问题而操作失败,比如文件或目录有可能被杀毒软件锁住扫描呢,所以都需要考虑失败到失败的情况。 * * * * * [当多线程并发遇到Actor](https://mp.weixin.qq.com/s/mzZatZ10Rh19IEgQvbhGUg) [改进日志写入并发问题 · top-think/framework@618fb51](https://github.com/top-think/framework/commit/618fb517888ad6baf03915316f1786fc52ffc6b9) [php高并发状态下文件的读写 - 踏雪无痕SS - 博客园](https://www.cnblogs.com/chenpingzhao/p/4796265.html) > 如果并发高,在我们对文件进行读写操作时,**很有可能多个进程对进一文件进行操作,如果这时不对文件的访问进行相应的独占,就容易造成数据丢失** (编程语言是在操作系统之上的,系统调用层面已经考虑了并发下冲突问题,所以我们程序层面应该是不会造成数据错乱和乱码的情况,但是会造成“更新丢失”的问题。) [数据库的最简单实现 - 阮一峰的网络日志](http://www.ruanyifeng.com/blog/2014/07/database_implementation.html) > 所有应用软件之中,数据库可能是最复杂的。 [【系统架构】大白话聊聊分布式事务](https://mp.weixin.qq.com/s/yBquVKyKnhpy2yPxY-0eqA) [同步锁的三种实现与案例解析 - CSDN博客](http://blog.csdn.net/u013630349/article/details/78826305) [Doug Lea并发编程文章全部译文 | 并发编程网 – ifeve.com](http://ifeve.com/doug-lea/) [谈谈高并发下的幂等性处理](https://mp.weixin.qq.com/s/iQOXjCZJK_o1tLnKNQcp8w) * * * * * #### 并发问题带来的后果:到底挨不挨打? ~~~ 老婆: 可不可以打你一巴掌,当前状态是可以,于是,老婆的手举起来开始挥过来了,这时,突然喊不能打,状态改为不能打,但是已经晚了,老婆的手已经停不了了,于是啪,一巴掌。老婆: 你不早说,反正我问你的时候你是说可以打的。 这就是并发没加锁的例子。 加锁是这样的,老婆: 可以打吗,我: 可以,老婆: 那我开打的过程中就不许在变了哦。 于是,再老婆: 打完之前,都不能再改状态了,所有要改状态和问状态的请求,都会被阻塞,只有打完啪之后才会释放锁,这样就没有任何异议了。 没有独占锁的问题,为文章添加标签,标签状态必须是可用的。更改标签状态和为文章添加可用标签两个请求的并发问题。1. 查询标签是否存在和状态,更改状态。2. 查询文章是否存在,查询标签是否存在,以及状态是否可用。是则更新文章标签。 并发问题。当2条件检查为是时,准备更新文章标签时,1把标签更改为不可用,那么就为文章添加了一个不可用的标签。这就出现了并发问题!!!并发问题往往是达不到预期的业务逻辑。要避免这个问题,1和2对标签的查询只能够使用独占锁才可以。 ~~~ * * * * * > 一般,数据展示时,都不会用锁,都是快照读,即无锁定读,只有当对数据进行变更时才会出现危险,所以任何对数据变更的地方都需要特别注意。 >[danger] **凡是有可能失败的地方,就一定会发生失败,所以检测、容错性要做好,不能偷懒!** >[danger] **更改数据那个地方,第一条sql就是查询,这个查询要上独占锁,只有这样下面更改才是安全的。** >[danger] 并不是说写代码是悲观主义,而是要严谨。 >[danger] 任何时候都要考虑并发,没有考虑并发问题的程序是不完整的,是不要严谨,不正确的,不合格的程序。 >[tip] 涉及到钱的一定要用锁! * * * * * ### 形象展示为什么会有并发问题 ![](http://cdn.aipin100.cn/18-7-6/49426434.jpg) 这就是并发问题,3操作对于进程2是不可见的,所以4这一刻还以为状态还是2时的,但其实不是的,也就是持有了旧数据,既然持有旧数据,那么程序可能就会和预期不一致,这就是并发问题。 小明 可用 小红 可用 小明 改为不可用 小红 刚看了是可用的 用锁的话 小明 只能我一个人看,可用 小红 有人再看,等一会吧 小明 改为不可用,走了 小红 轮到我看了,也只准我一个人看,不可用 小红 哦,不可用啊,我走了 数据不会被两个人同时共享,每个人,每一刻持有的都是最新数据,不可能持有到旧数据。 将并行问题变成串行来解决,这也体现了锁其实就是一种排队,或者说是控制并发程序访问共享资源的顺序和规则。 * * * * * [MySQL 返回更新值(RETURNING)_四爷-CSDN博客_mysql returning](https://blog.csdn.net/yueliangdao0608/article/details/41643897) > update ... returning 特性可以用来在业务代码中非常方便的实现某些防止并发问题的功能,可惜 mysql 不支持 ---- last update:2018-4-10 17:54:21
';