大学网站开发的流程,电商网站 技术,山东软件开发的公司,广州做网站建设大家好#xff0c;我是 方圆。最近在接口联调时发生了数据并发修改问题#xff0c;我想把这个问题讲解一下#xff0c;并把当时提出的解决方案进行实现#xff0c;希望它能在大家以后在遇到同样的问题时提供一些借鉴和思考的方向。原文还是收录在我的 Github: enthusiasm 中…大家好我是 方圆。最近在接口联调时发生了数据并发修改问题我想把这个问题讲解一下并把当时提出的解决方案进行实现希望它能在大家以后在遇到同样的问题时提供一些借鉴和思考的方向。原文还是收录在我的 Github: enthusiasm 中欢迎Star和获取原文。
1. 问题背景
问题发生在快递分拣的流程中我尽可能将业务背景简化让大家只关注并发问题本身。
分拣业务针对每个快递包裹都会生成一个任务我们称它为 task。task 中有两个字段需要关注一个是分拣中发生的 异常exp_type另一个是分拣任务的 状态status。另外需要关注 分拣状态上报接口通过它来记录分拣过程中的异常和状态变更。 一般情况下分拣机在分拣异常发生时会及时调用接口上报在分拣完成时调用接口来标记为完成状态两次接口调用的时间间隔较长不会发生并发问题。
但是有一种特殊的分拣机它不会在异常发生时及时上报而是在分拣完成时将分拣过程中发生的异常和分拣结果一起上报那么此时分拣状态上报接口在同一时间内就会有两次调用这时便发生了预期外的并发问题。
我们先看下分拣状态上报接口的执行流程 先查询到该分拣任务 task默认情况下 exp_type 和 status 均为默认值0 分拣异常修改 task 中的 exp_type分拣完成修改 status 字段信息 修改完成将 task 写入
并发问题发生的图示如下 数据库初始值为 1, 0, 0分拣异常和分拣完成几乎同时上报它们都读取到该值。分拣异常动作将 exp_type 修改为9写入数据库此时数据库值为 1, 9, 0分拣完成动作将 status 修改为1写入数据库使得数据库最终值为 1, 0, 1它将异常字段的值覆盖掉了。正常情况下最终值应该为 1, 9, 1分拣完成动作应该读取到分拣异常完成后的值 1, 9, 0 后再进行修改才对。
2. 解决方案
发生这个问题的原因很容易就能发现两个事务同时执行 读取-修改-写入 序列其中一个写操作在没有合并另一个写操作变更的情况下直接覆盖了另一个写操作的结果所以导致了数据的丢失。
这种问题是比较典型的 丢失更新 问题可以通过对数据库读操作加锁或者改变数据库的隔离级别为可串行化使事务串行执行的方式进行避免。下面我会将大家在讨论避免丢失更新问题时提出的方案进行介绍并尽可能的用代码来表现它们。
2.1 数据库读操作加锁和可串行化隔离级别
我们可以考虑如果对每条Task数据修改的事务都是在当前事务完成之后才允许后续事务进行修改使事务串行执行那么我们就能够避免这种情况。比较直接的实现是通过显式加锁来实现如下
select exp_type, status
from task
where id 1
for update;先查询该行数据的事务会获取到该行数据的 排他锁后续针对该数据的所有读写请求都会被阻塞直到先前事务执行完将锁释放。
这样通过加锁的方式实现了事务的串行执行。但是在为SQL添加加锁语句时需要确定是不是为该行数据加锁而不是锁住了整个表如果是后者那么可能会造成系统性能严重下降而且还需要关注有哪些业务场景使用到了该SQL是否存在长时间执行的只读事务使用如果存在的话可能会出现因加锁导致延迟和系统性能下降所以需要谨慎的评估。
此外可串行化的数据库隔离级别也能保证事务的串行执行不过它针对的是所有事务。一般情况下为了保证性能我们不会采用这种方案默认使用MySQL可重复读隔离级别。 MySQL的InnoDB引擎实现可串行化隔离级别采用的是2PL机制在第一阶段事务执行时获取锁第二阶段事务执行完成释放锁。 2.2 针对业务只修改必要字段
如果异常状态请求仅修改 exp_type 字段分拣完成仅修改 status 字段的话那么我们可以梳理一下业务逻辑仅将必要修改的字段写入数据库这样就不会发生丢失更新的异常如下代码所示
// 处理异常状态请求封装修改数据的对象
Task task new Task();
tast.setId(id);
task.setExpType(expType);// 更改数据
taskService.updateById(task);在执行修改数据前创建一个新的修改对象并只为其必要修改字段赋值。但是还需要考虑的是如果这个业务流程处理已经很复杂了很可能不清楚该为哪些字段赋值而导致再发生新的异常所以采用这种方法需要对业务足够熟悉并且在修改完后进行充分的测试。
2.3 分布式锁
分布式锁的方法与方法一类似都是通过加锁的方式来保证同时只有一个事务执行区别是方法一的锁加在了数据库层而分布式锁是借助Redis来实现。
这种实现方式的好处是锁的粒度小发生锁争抢仅限于单个包裹无需像数据库加锁一样去考虑锁的粒度和对相关业务的影响。伪代码如下所示
// 分布式锁KEY
String distributedKey String.format(DISTRIBUTED_KEY_PREFIX, packageNo);
try {// 分布式锁阻塞同一包裹号的修改lock(distributedKey);// 处理业务逻辑handler();
} finally {// 执行完解锁redissonDistributedLocker.unlock(distributedKey);
}需要注意lock() 加锁方法要保证加锁失败或发生其他异常情况不影响业务逻辑的执行并设定好锁持有时间和等待锁的阻塞时间此外解锁方法务必添加到 finally 代码块中保证锁的释放。
2.4 CAS
CAS是乐观的解决方案它一般通过在数据库中增加时间戳列来记录上次数据更改的时间当新的事务执行时需要比对读取时该行数据的时间戳和数据库中保存的时间戳是否一致以此来判断事务执行期间是否有其他事务修改过该行数据只有在没有发生改变的情况下才允许更新否则需要重试这个事务。样例SQL如下所示
update task
set exp_type #{expType}, status #{status}, ts #{currentTs}
where id #{id} and ts #{readTs}它的原理不难理解但是实现起来可能会存在困难因为需要考虑在执行失败后该如何重试重试的方式和重试的次数需要根据业务去判断。 巨人的肩膀
《数据密集型应用系统设计》第七章 事务