系统设计 — 并发控制

并发应该是软件开发中最难处理的问题之一。因为在多进程或是多线程操作一个数据时候,为了保证数据的一致性就必然碰到并发。这个数据可能存在于内存中,也可能存在一个持久化设备中,更复杂的情况是存在于多个数据源中。例如一个订单请求,库存存在于一个数据源中,在库存安全的情况下,去检测用户的金额是否足够,这个是存在于另一个数据源中,在完成两者的检查都满足条件下,去建立一个订单,但是可能在你检查金额的过程中,另一个用户成功下单,并修改了库存信息,而在你检查完金额后,实际库存已经不满足你的下单条件了,但是你还是下单成功了,如此就是并发导致的问题。术语上称之为离线并发。

并发的原因
上面那个例子是造成并发的一个原因:不一致读。在并发的读取多个数据源数据的情况下会存在,另一个并发问题原因是:更新丢失,这个也是我们日常最多取考虑的问题,还是拿下单来说,两个请求同时读取了库存为40,再同时去更新库存的,正确的结果应该是38,但是因为库存40已经读取到内存中,双方都同时更库存更新为39.实际这个并不是我们要的结果。

对于第二个导致并发的问题:更新丢失,我们可以采用的手段是隔离:划分数据,每一个数据都只能被一个执行单元执行。常见的例子是我们需要获取一个数据,并独占的对这个数据进行读写,那么就建立一个文件锁,在一个请求在未释放文件锁条件下,其他请求都无法访问该数据。
只有变化的数据才会有并发问题,当然不变化的数据就不会有并发问题。

在操作系统层面,我们对于请求的处理有两种方式,一是一个请求一个进程,进程是一个重量级的执行语境,会分配单独的内存,能够解决在内存层面的并发问题,但是无法解决离线并发的问题,缺点是太耗费系统资源。另一种是一个请求一线程,线程是一个轻量级的执行单元,一个进程内可以存在多个线程,多个线程共享一片内存,那么一个独立数据就会面临并发问题。因为JAVA领域采用的是线程模型,所以也是我们程序员苦苦在追求和解决线程间并发的问题。优点是高效利用资源,缺点是并发问题严重。
大多数应用选择的还是线程模型,因为现在的持久化设备:mysql和oracle能够很好的解决单数据源的并发处理,所以留给我们程序员的就是内存层面的并发,也就是线程并发问题。

乐观锁和悲观锁
无论是持久化层面的并发处理还是内存层面或是离线并发,通常的处理方式有两种:
1.乐观并发锁 也就是说可以两个请求同时更新数据,A请求率先更新了数据,B请求在数据在更新时,发现跟原本取出来的不一致,那么就拒绝更新数据,并提示B请求改怎么去处理,这个检测不一致的手法常用的有版本号(时间戳也是很好的手段),拉取数据时候同时带出当时数据的版本号,在更新数据时候,更新版本号,那么另一个请求想要更新数据时就会发现版本号的不一致,就提示拒绝更新!

2.悲观并发锁 在一个请求获取数据的时候,另一个线程根本无法获得数据,只有第一个请求处理完成后,才能获取数据。主要手段是分为读锁和写锁,读锁是共享锁,可以一次被多个请求持有,但且还有一个请求持有读锁,那么其他请求就无所获得写锁,若是一个请求持有了写锁,那么其他请求都将无法获得读锁或是写锁。
可以说,乐观锁是关于冲突检查的,而悲观锁是关于冲突避免的,不给机会发生冲突。悲观锁是减少并发的程度,乐观锁相对自由些,只在更新时候才提示是否会存在并发问题。
两者的选择在与冲突的频率和严重性,若是冲突的后果不是很严重的话,乐观锁是一个不错的选择,因为能够得到很好的并发性。若是并发造成的后果非常严重,那么优先选择悲观锁。

乐观锁和悲观锁都能很好的解决更新丢失和单数据源的不一致读,但是对于多数据源的不一致读就无能为力,解决办法是通过一个多数据源控制器,这个控制器维护一个时序列表,对于每个数据源的更新都采用乐观锁或是悲观锁,在其中一个数据源处理失败时,完全的回滚事务。

死锁
既然有锁,那么就会存在死锁的情况,死锁就是存在多个锁,A请求获得了A1锁,B请求获得了B1锁,同时双方都需要去获得对方的锁,这个时候A1和B1都被持有的,但两个请求都在等待对方的释放,如此就导致了死锁,处理死锁的常见方式就是:超时控制和死锁检测,死锁检测的实现复杂度较高,更多采用的方式就是超时控制,在java中,对于超时已经有了很好的处理机制。
还有种处理死锁的方式就是一个请求在开始获得锁的时候,一起获得所有可能的锁,如此也能避免死锁,但是这种陈本更高。

以上所说的保证数据的正确性或是安全性,用专业的术语将就是事务,那么体现正确性就需要有以下几点要求:
1、原子性(Atomicity):动作序列的每个步骤要么全部成功,要么全部回滚。
2、一致性(Consistency):事务开始和结束时,系统资源必须处于一致状态。
3、隔离性(Isolation):一个事务,直道提交之后,其结果才对其他事务可见。
4、持久性(Durability):一个已提交事务的任何结果都必须是永久性的。

事务
对于夸多数据源的事务我们称之为:长事务。常见的事务是请求开始时候,开启事务,在请求结束时候关闭事务。但是在面临高并发的时候,如此就会影响吞吐量,还有种方式在写数据的时候才开启时候,如此提高效率,但是会带来的问题,不一致读,这种做法一般不推荐做,除非真的竞争很激烈。

对于事务分为两类,一是系统事务,也就是单个数据源保证的事务(数据库),还有种就是业务事务,也是我们一直想解决的问题,离线并发问题。解决方案前面也大致提到了,抽取出一个事务控制器,通过维护一个事务序列,采用乐观离线锁或是悲观离线锁,来控制各个数据源的事务,至于选择的原则就是冲突的频率和严重性,若是一个请求数需要用户花费大量的时间来填写页面的表单,若是你采用的乐观锁,只有在提交的时候才告诉用户,并发冲突了,那么用户体验是非常不好的,发现失败的代码太好了。悲观锁就能很快的发现错误,但是也会带来灵活性上的损失。

还有种处理离线并发就是将多数据源数据的副本集中到一个地方,统一的对这个地方坐乐观锁或是悲观锁处理!但是需要维护的就是副本和源数据之间的更新!

前面提过了操作系统层面的回话访问分为进程和线程两个级别,进程的优点就不多提了,缺点是耗费资源,也就是创建进程的代价大,服务的资源毕竟是非常有限的,那么改进的方式是复用进程,自己维护一个进程池,但是一定的进程若还是不能满足我们的吞吐量要求,那么再进一步的改造方式就是一个进程中开启多个线程进行处理,如此带来的就是需要合理的处理并发问题。同时还有就是这个进程挂掉后,在这个进程中的所有线程都会挂掉。
apache中的prefork模式就是一个请求一个进程,而worker模式就是一个进程多个线程,至于选择还依赖于很多因素,例如系统方面的cpu和业务上的要求等等,具体这里就不展开了!

作者: inter12

在这苦短的人生中,追求点自己的简单快乐

发表评论

电子邮件地址不会被公开。 必填项已用*标注