并发导致丢失更新问题的多种解决方案

Java技术 潘老师 9个月前 (08-15) 262 ℃ (0) 扫码查看

本文是京东团队在实战过程中出现由于并发问题导致丢失更新情况,从而提供的多种解决方案实现思路,大家可以参考借鉴下。

1. 问题背景

问题出现在快递分拣流程中,我将业务背景进行简化,侧重于突显并发问题。

在分拣业务中,每个快递包裹都对应着一个任务,我们将其称为“task”。在这些任务中,有两个关键字段需要关注,一个是记录分拣过程中发生异常的“exp_type”(异常类型),另一个是标明分拣任务当前所处状态的“status”(状态)。另外,我们还需特别留意分拣状态上报接口,通过这个接口来准确记录分拣过程中出现的异常情况以及状态的变动。

通常情况下,快递分拣机在遇到异常情况时会迅速调用接口进行上报,而在分拣完成时也会使用接口来标记任务为已完成状态。这两次接口调用之间存在较长的时间间隔,因此通常不会发生并发问题。

然而,有一种特殊情况下的分拣机行为不同,它并不会在异常发生时立即上报异常情况,而是在分拣任务最终完成时将整个过程中的异常情况和分拣结果一并上报。这样一来,在同一时刻内,分拣状态上报接口就会被调用两次,这就导致了意料之外的并发问题。

让我们详细看一下分拣状态上报接口的执行流程:

  1. 首先,系统会查询特定的分拣任务(task),初始时该任务的异常类型(exp_type)和状态(status)字段均被设置为默认值0。
  2. 当分拣机遇到异常情况时,它会修改任务中的异常类型字段(exp_type)来记录异常的性质。
  3. 分拣完成后,分拣机会修改任务中的状态字段(status)来标记任务为已完成状态。
  4. 完成修改后,分拣任务的信息将被写入系统。

在这个特殊情况下,因为分拣机在分拣过程中可能会同时上报异常和完成状态,两次接口调用会在短时间内发生,从而导致了并发问题的发生。

并发问题发生的图示如下:

数据库的起始数值为 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(Two-Phase Locking)机制实现的:在第一阶段获取所需的锁,在第二阶段完成事务并释放锁。

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}

这个方法的核心概念并不复杂,但在实际实现过程中可能会遇到一些困难。主要是因为需要仔细考虑在操作失败后应该如何进行重试。重试的方式以及重试次数都需要根据具体的业务情况来进行判断和设定。

总结

在处理数据更新时,业务逻辑的复杂性可能导致难以预测的异常状况。为了避免数据丢失或更新错误,我们可以采用针对业务的精细修改、分布式锁、CAS(比较并交换)等策略。这些方法各有优劣,需要根据业务需求和性能要求进行选择。

然而,实施这些策略时需要谨慎。在使用锁机制时,要确保锁的加锁和释放不会影响正常的业务逻辑。CAS方法需要考虑失败后的重试机制,以及重试次数的合理设定。此外,对于每种策略,都需要根据具体业务情境进行充分的测试和验证,以确保系统在实际运行中能够保持数据的一致性和正确性。

综上所述,保障数据一致性需要综合考虑多种因素,包括业务复杂性、性能要求和异常处理等。通过选择适合业务的策略,并且在实施过程中充分考虑各种情况,可以最大限度地降低数据更新过程中出现问题的风险,确保系统的稳定和可靠运行。


版权声明:本站文章,如无说明,均为本站原创,转载请注明文章来源。如有侵权,请联系博主删除。
本文链接:https://www.panziye.com/java/8223.html
喜欢 (0)
请潘老师喝杯Coffee吧!】
分享 (0)
用户头像
发表我的评论
取消评论
表情 贴图 签到 代码

Hi,您需要填写昵称和邮箱!

  • 昵称【必填】
  • 邮箱【必填】
  • 网址【可选】