如何在高并发下,保证共享数据的一致性
在高并发环境下,要保证共享数据的一致性,核心在于通过一系列严谨的、从数据库到应用架构层面的“并发控制”机制,来确保多个并行的操作,在逻辑上,能够像“串行”执行一样,产生一个确定的、符合业务规则的正确结果。一套全面、健壮的数据一致性保障体系,其构建必须系统性地涵盖五大关键策略:运用“锁”机制保障操作的“互斥性”、通过“事务”确保一系列操作的“原子性”、在分布式系统中选择合适的“一致性模型”、利用“消息队列”等工具实现“最终一致性”、以及在架构设计层面采用“无锁”或“数据分片”等策略。其中,通过“事务”确保一系列操作的“原子性”,是保障数据一致性的“基石”。
一个事务,就如同一个“保险箱”,它将一系列独立的数据库读写操作,都包裹在了一个“要么全部成功、要么全部失败”的、不可分割的单元之中。如果在事务执行的过程中,发生了任何错误,整个事务,都会被“回滚”到操作开始前的原始状态,从而,从根本上,杜绝了数据,因为“操作执行了一半”而被遗留在一种“中间的、不一致的”残缺状态的风险。
一、问题的根源、并发下的“数据混沌”
在探讨“如何保证”之前,我们必须首先,深刻地,理解“为何会不一致”。在单用户、低并发的环境中,数据的一致性,通常是自然而然的。然而,一旦进入高并发的世界,多个用户或进程,在几乎同一微秒,对同一个共享数据,进行“读取-修改-写回”的操作时,一场“数据的混沌”风暴,就将不可避免地降临。
1. 经典的“商品库存”场景
这是一个最能直观地,揭示并发问题所在的经典案例:
场景:一件热门商品,其在数据库中的库存,只剩下最后1件。
并发事件:在几乎完全相同的时间点,用户A和用户B,都点击了“购买”按钮。这两个请求,被分配到了服务器的两个不同线程(线程A和线程B)上,并行地开始处理。
一个未经保护的、灾难性的执行时序:
时刻1:线程A,从数据库中,读取商品库存,得到值为1
。
时刻2:线程A,在内存中,进行逻辑判断:1 > 0
,库存充足,可以购买。
时刻3:此时,发生了一次线程切换! 操作系统,暂停了线程A的执行,转而去执行线程B。
时刻4:线程B,也从数据库中,读取商品库存。因为线程A的修改,尚未被写回,所以,它读取到的值,依然是1
。
时刻5:线程B,在内存中,也进行了逻辑判断:1 > 0
,库存充足,可以购买。
时刻6:线程B,执行“写回”操作,将库存修改为1 - 1 = 0
,并更新到数据库中。然后,为用户B,创建了订单。
时刻7:线程再次切换,回到线程A。
时刻8:线程A,从上次被暂停的地方,继续执行。它基于自己早已“过时”的、在时刻1读取到的旧数据,执行“写回”操作,将库存,再次,修改为1 - 1 = 0
,并为用户A,也创建了订单。
最终结果:一件商品,被成功地,卖给了两个人。数据库中的库存,变为了-1
(如果字段允许负数)。数据,进入了严重的“不一致”状态。
2. 一致性的“定义”
数据一致性,是指数据,在其生命周期中的任何时间点,都必须,处于一种符合预设业务规则的、有效的、逻辑自洽的状态。例如,“商品库存,永远不能为负数”,就是一条业务规则。
二、方案一、“单体”世界中的“强一致性”
对于那些运行在“单个”数据库服务器上的、传统的“单体”应用而言,我们通常,会追求一种最高级别的、可立即验证的“强一致性”。
1. “锁”机制:从“悲观”到“乐观”
“锁”,是实现并发控制的、最基础、也最直接的工具。
悲观锁:它秉持一种“悲观”的哲学,即“假设冲突,总是会发生”。因此,它在对数据,进行任何操作之前,会先** preemptively**地,将这条数据“锁定”。
实现方式:例如,在数据库中,使用SELECT ... FOR UPDATE
语句。当一个事务,执行了这条语句后,数据库,就会将该行数据锁定。任何其他的事务,如果也想修改这一行,就必须“排队等待”,直到前一个事务,完成并“释放”锁为止。
优缺点:数据一致性保障极高,但因为“串行化”了并行操作,所以,会牺牲掉一部分的“并发性能”。
乐观锁:它秉持一种“乐观”的哲学,即“假设冲突,是小概率事件”。它不会在操作前加锁,而是在**最终“提交更新”**的那一刻,去检查,数据,是否已经被其他线程所修改。
实现方式:通常,会在数据表中,增加一个“版本号”字段。
线程A,读取数据时,会同时读取其“版本号”(例如,v=1
)。
当线程A,准备写回数据时,它的更新语句,会变成:UPDATE products SET stock = 0, version = 2 WHERE id = ? AND version = 1
。
如果,在此期间,线程B,已经抢先一步,修改了库存,那么,该行数据的版本号,就已经变成了2
。此时,线程A的UPDATE
语句,会因为version = 1
这个条件不满足,而更新失败(影响行数为0)。
优缺点:并发性能,远高于悲观锁。但如果“冲突”频繁发生,那么,大量的“重试”操作,反而会降低整体性能。
2. 数据库“事务”:原子性的保障
事务,是将一系列独立的数据库读写操作,打包成一个“不可分割”的、“要么全部成功,要么全部失败”的“原子”单元的、最核心的机制。 一个符合ACID标准(即原子性、一致性、隔离性、持久性)的事务,能够从根本上,保障在一个操作序列中的数据一致性。 在开篇的“商品库存”案例中,只要我们将“读取库存 -> 判断库存 -> 修改库存”这三个步骤,完整地,包裹在一个“事务”中,并配合“悲观锁”,那么,当线程A,在事务中,执行SELECT ... FOR UPDATE
时,线程B的SELECT ... FOR UPDATE
请求,就会被阻塞,直到线程A的整个事务,提交或回滚为止。
三、挑战的“升维”:分布式系统的一致性
当我们的业务,发展到需要将数据,分散存储在多个独立的服务器(即“分布式系统”)上时,一致性的保障,其难度,会呈指数级上升。
1. CAP定理的“三难困境”
CAP定理,是分布式系统领域的“基石”理论。它指出,任何一个分布式系统,在以下三个核心指标中,最多,只能同时,满足其中两个:
一致性:任何一次读操作,都能读取到“最新的”写操作的结果。
可用性:任何一次请求,都能收到一个“非错误”的响应,但不保证数据是最新的。
分区容错性:系统在遇到“网络分区”(即节点间的网络连接中断)时,依然能够继续对外提供服务。
在现实世界的、一个必然会遇到网络故障的分布式系统中,“分区容错性”,是一个必须被保障的基础。因此,我们,常常被迫地,在“一致性”和“可用性”之间,做出一个艰难的“权衡取舍”。
2. BASE理论与“最终一致性”
为了应对这种“三难困境”,许多大型的互联网系统,都选择,放弃“强一致性”,而遵循“BASE理论”,追求一种更具弹性和扩展性的“最终一致性”。
BASE理论,即基本可用、软状态和最终一致。
最终一致性,意味着,系统,并不保证,在数据写入后的“任何时刻”,所有节点上的数据,都是完全一致的。但它“承诺”,在经过了一个短暂的“不一致窗口期”之后,所有节点上的数据,最终,都将会,达到一个一致的状态。
四、方案二、“分布式”世界中的“最终一致性”
1. 分布式事务的“两阶段提交”与“补偿事务”
为了在分布式环境下,实现多个独立服务之间的“事务性”操作,业界发展出了“两阶段提交”、“补偿事务”等复杂的分布式事务协议。它们,通过引入一个“协调者”的角色,来尽力地,模拟传统单体数据库的事务行为。但因为其实现的复杂性和对性能的巨大影响,在许多互联网场景下,团队更倾向于,采用基于“消息”的、更松耦合的最终一致性方案。
2. 核心模式:基于“消息队列”的异步通信
消息队列(如Kafka, RabbitMQ),是实现最终一致性的、最常用、也最可靠的“核心基础设施”。
场景:在一个电商系统中,“订单服务”,在创建了一个新订单后,需要通知“库存服务”,去扣减库存,并通知“积分服务”,去为用户增加积分。
最终一致性的实现:
“订单服务”,在自己的本地数据库事务中,成功地,创建了订单。
然后,它并不直接地,去远程调用“库存服务”和“积分服务”的接口(因为,这些调用,可能会失败或超时)。
取而代之,它,向一个高可用的“消息队列”中,发送一条包含了订单信息的、可靠的“订单已创建”的消息。
“库存服务”和“积分服务”,则作为“消费者”,分别地,“订阅”了这个消息。
当它们,收到这条消息后,再各自地,在自己的服务内部,执行“扣减库存”和“增加积分”的操作。
优点:这种模式,通过“消息中间件”,将原本紧耦合的、同步的调用,进行了解耦,极大地,提升了系统的“可用性”和“弹性”。即便“库存服务”暂时宕机,也不会影响到“订单服务”的正常运行。
五、在流程与实践中“抉择”
一致性,并非一个“越高越好”的单一指标。在不同的业务场景下,我们需要,有策略地,选择不同“级别”的一致性模型。
需要“强一致性”的场景:所有与“金钱”和“交易”直接相关的核心流程,例如,支付、下单、核心库存等。在这些场景下,我们必须,不计代价地,采用数据库的“事务”和“悲观锁”等机制,来保障数据的绝对一致。
可接受“最终一致性”的场景:大量的、非交易核心的场景,例如,用户动态的更新、文章点赞数的显示、非核心信息的跨系统同步等。在这些场景下,短暂的数据不一致,对用户体验的影响很小,我们可以,放心地,采用基于“消息队列”的最终一致性方案,来换取系统更高的可用性和可扩展性。
常见问答 (FAQ)
Q1: “强一致性”和“最终一致性”哪个更好?
A1: 两者并无绝对的“好坏”之分,只有“适用场景”的不同。“强一致性”,提供了最可靠的数据保证,但牺牲了部分的“性能”和“可用性”。而“最终一致性”,则通过接受一个短暂的“不一致”窗口,来换取系统极高的“可用性”和“可扩展性”。
Q2: 什么是“ACID”?
A2: “ACID”,是数据库事务,必须具备的四个核心特性:原子性(一个事务,是不可分割的工作单元,要么全做,要么全不做)、一致性(事务,必须使数据库,从一个一致性状态,转变到另一个一致性状态)、隔离性(并发执行的事务之间,不应相互干扰)和持久性(一旦事务提交,其结果,就是永久性的)。
Q3: 为什么说在分布式系统中,我们必须在“一致性”和“可用性”之间做出选择?
A3: 这是由“CAP定理”所决定的。因为,在分布式系统中,“网络分区”(即网络故障)是不可避免的。当网络分区发生时,一个节点,为了保证“一致性”,就必须拒绝那些可能“过时”的读写请求,从而,牺牲了“可用性”。反之,如果它为了保证“可用性”,而继续处理请求,那么,它所处理的数据,就可能是“过时”的,从而,牺牲了“一致性”。
Q4: “乐观锁”和“悲观锁”有什么区别?
A4: 两者的核心区别,在于对“并发冲突”的“假设”不同。“悲观锁”,假设冲突“很可能会发生”,所以,在操作数据“之前”,就先将其“锁定”。而“乐观锁”,则假设冲突“很少会发生”,所以,它在操作前“不加锁”,而只是在最终“提交更新”时,才去“检查