对于企业应用来说,完全不涉及到并发的问题,基本是不可能的。因为对于一个应用中很多的事情都是同时进行的。并发可能发生在数据获取,服务调用乃至于用户交互中。并发问题有两个重要的解决方案,一个是隔离,另一个是不变性。
并发问题会发生在多个执行单元同时访问同一资源的时候,此时,一个好的方法就是分好“蛋糕”,让每一个执行单元都能访问到各自的资源。好的并发设计就是:找到创建好隔离区的办法,然后通过分析工作流让隔离区能够完成尽可能多的任务。
在共享数据可以改变的情况下,并发问题就有可能发生。从实际的场景出发,同时有两个客户询问两位服务员是否还有某一货品时,两位服务员各自去查看了一下系统并回复客户还有一份,两位客户中一定有一位会失望。那么这件事情的解决方案就是添加隔离区(购物车),服务员把当前货品放入客户的购物车成功后告知用户,然后失败的一方就可以告知用户货品已经销售一空。虽然存在已购用户退货的可能,但无疑比前一结果要好太多。这也就是下文中所说的悲观锁。
下面我们开始介绍两种并发控制策略:
乐观和悲观并发控制
在某个系统中,同时有两个企业员工 A 和 B 想要编辑同一个用户信息。此时 A 和 B 都获取到了用户的信息数据。然后他们两个进行了修改,A 员工先完成了操作并且进行了提交。然后 B 员工完成了操作也进行了提交。此时系统中的这个用户信息只保留了 B 提供的数据,而丢弃了 A 员工的数据。这可能会造成一些难以预料的问题,甚至有可能导致他们丢掉工作。虽然可以通过操作日志来追溯到是哪个员工操作了数据,但这个信息没有任何意义,因为系统并没有让任何员工得知修改这一情况。
当一些可变数据无法隔离时候,我们可以用两种不同的控制策略:乐观锁策略和悲观锁策略。乐观锁用于冲突检测,悲观锁用于冲突避免。
悲观者策略非常简单,当 A 用户获取到用户信息时系统把当前用户信息给锁定,然后 B 用户在获取用户信息时就会被告知别人正在编辑。等到 A 员工进行了提交,系统才允许 B 员工获取数据。此时 B 获取的是 A 更新后的数据。
乐观者策略则不对获取进行任何限制,这时候我们可以在用户信息中添加版本号来告知用户信息已被修改。乐观锁要求每条数据都有一个版本号,同时在更新数据时候就会更新版本号,如 A 员工在更新用户信息时候提交了当前的版本号。系统判断 A 提交的时候的版本号和该条信息版本号一致,允许更新。然后系统就会把版本号修改掉,B 员工来进行提交时携带的是之前版本号,此时系统判定失败,要求 B 重新获取数据和版本号,然后再一次进行提交。
乐观锁和悲观锁进行选择的标准是: 冲突的频率和严重性。如果冲突的结果对于用户是难以接受的,我们只能采用悲观锁策略。如果冲突的结果不会很严重,或者频率也较低,我们就可以选择乐观锁,它更容易实现,也具有更好的并发性。
当然,我们也可以对乐观锁进行一些优化,把更新时间和更新用户添加到信息中,如此以来,系统就可以告知 B 员工该条信息被修改过,以及在何时何人操作。系统还可以提供给 B 新的更新时间以及是否强制更新的选择。当然,甚至可以基于业务需求以及日志信息等来告知 B 员工之前具体修改的信息。
死锁
使用悲观锁技术有一个特别的问题就是死锁,即用户在已经获取锁的情况下还想要获取更多的锁。以最早的两个客户的问题来说,就是水果蛋糕需要获取水果和蛋糕,两个用户各有其中一种,并期望获取对方东西。
解决死锁的方法是检测处理和超时控制。
检查处理会检测出死锁发生并且会选择一个“牺牲者”,让他放弃他所拥有的已保证另外一个客户可以获取水果蛋糕。而超时控制则是给每个锁添加一个超时时间,一旦达到了超时时间,当前的购物车里面的物品就被清掉。
超时控制和检测机制用于已经发生了死锁的情况,而另外的方法则是避免死锁的发生。防止死锁的方法就是在用户获取锁的时候就获取所有可能需要的锁,粗力度锁(这很保守,但很有效),即水果蛋糕不是由两个货品组合而成的。
粗力度锁是覆盖多个资源的单个锁,这样会简化多个锁带来的复杂性。这其实也会发生在乐观锁的过程中,例如用户和用户相关地址信息,如果用户地址信息修改后也会更改用户信息,这样如何获取和设置乐观锁呢?我们需要寻找到一组资源的核心。
同时,找到一组资源的核心也会使得开发的代码逻辑更加清晰。大家不妨想一下,在数据库层面的操作中,是选择先更新子表然后再去更新主表这样的逻辑顺序更好,还是以主表为入口进行更新修改更好呢?
鼓励一下
如果你觉得这篇文章不错,希望可以给与我一些鼓励,在我的 github 博客下帮忙 star 一下。