问小白 wenxiaobai
资讯
历史
科技
环境与自然
成长
游戏
财经
文学与艺术
美食
健康
家居
文化
情感
汽车
三农
军事
旅行
运动
教育
生活
星座命理

高并发下的数据一致性保障(图文全面总结)

创作时间:
作者:
@小白创作中心

高并发下的数据一致性保障(图文全面总结)

引用
1
来源
1.
https://www.cnblogs.com/wzh2010/p/18031204#scroller-2

在高并发场景下,数据一致性是分布式系统设计中一个非常重要且复杂的问题。本文将从背景、分布式常见一致性问题、一致性解决方案等多个维度,详细探讨如何在高并发场景下保障数据一致性。

1 背景

在分布式系统中,数据一致性是一个核心问题。CAP理论指出,在分布式系统中,一致性(Consistency)、可用性(Availability)和分区容错性(Partition Tolerance)这三个属性不可能同时满足,系统设计者需要根据具体场景做出权衡。

  • 一致性(Consistency):在分布式系统中,所有实例节点在同一时间看到的是相同的数据。
  • 可用性(Availability):确保每一个请求都能接收到响应,即使响应可能不是最新的数据。
  • 分区容错性(Partition Tolerance):系统在遇到网络分区故障时,仍然能够继续运行。

本文将聚焦于高并发场景下如何保障数据一致性。

2 分布式常见一致性问题

2.1 典型支付场景

这是最经典的场景。支付过程需要先查询买家的账户余额,然后计算商品价格,最后对买家进行扣款。在低并发情况下,这种操作没有问题,但在高并发场景下,"查询+修改"的操作很可能导致数据不一致性。

2.2 在线下单场景

买家在电商平台下单时,通常会涉及到两个动作:扣库存和更新订单状态。由于库存和订单往往属于不同的数据库,因此需要使用分布式事务来保证数据一致性。

2.3 跨行转账场景

跨行转账是一个典型的分布式事务场景。用户A向用户B转账时,需要先从A的账户中减去转账金额,然后在B的账户中增加相同金额。由于涉及不同银行的业务平台,因此需要引入数据一致性方案来保证这两个操作步骤的一致性。

3 一致性解决方案

3.1 分布式锁

分布式锁是解决数据一致性问题的一种常见方案。常见的分布式锁实现方案有三种:

  1. 基于数据库实现分布式锁
  2. 基于缓存(如Redis)实现分布式锁
  3. 基于Zookeeper实现分布式锁

这三种方案的实现复杂度和性能各有不同:

能力组件
实现复杂度
性能
可靠性
数据库
缓存
Zookeeper

其中,基于缓存(如Redis)的分布式锁实现最为常见。以下是基于Redis实现分布式锁的示例代码:

// 设置账户Id为17124的账号的值为1,如果不存在的情况下,并设置过期时间为500ms
SET pay_id_17124 1 NX PX 500
// 进行删除
DEL pay_id_17124

实现锁机制的原理是:这个命令只有在某个key不存在的时候才会执行成功。当多个进程同时并发地去设置同一个key时,就永远只会有一个进程成功。解锁很简单,只需要删除这个key就可以了。

3.1.2 缓存实现分布式锁的优缺点

优点:Redis相比于MySQL和Zookeeper性能好,实现起来较为方便。

缺点:通过超时时间来控制锁的失效时间并不是十分的靠谱;这种阻塞的方式实际是一种悲观锁方案,引入额外的依赖(Redis/Zookeeper/MySQL等),降低了系统吞吐能力。

3.2 乐观模式

对于概率性的不一致的处理,需要乐观锁方案,让你的系统更具健壮性。分布式CAS(Compare-and-Swap)模式就是一种无锁化思想的应用,它通过无锁算法实现线程间对共享资源的无冲突访问。

CAS模式包含三个基本操作数:内存地址V、旧的预期值A和要修改的新值B。在更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。

我们以2.1节的典型支付场景作为例子分析:

  • 初始余额为 800
  • 业务1和业务2同时查询余额为800
  • 业务1执行购买操作,扣减去100,结果是700,这是新的余额。理论上只有在原余额为800时,扣减的Action才能执行成功。
  • 业务2执行生活缴费操作(比如自动交电费),原余额800,扣减去200,结果是600,这是新的余额。理论上只有在原余额为800时,扣减的Action才能执行成功。可实际上,这个时候数据库中的金额已经变为600了,所以业务2的并发扣减不应该成功。

根据上面的CAS原理,在Swap更新余额的时候,加上Compare条件,跟初始读取的余额比较,只有初始余额不变时,才允许Swap成功,这是一种常见的降低读写锁冲突,保证数据一致性的方法。

go 代码示例:

package main

import (
    "fmt"
    "sync/atomic"
)

// Compare 函数比较当前值与预期值是否相等
func Compare(addr *uint32, expect uint32) bool {
    return atomic.LoadUint32(addr) == expect
}

func main() {
    var value uint32 = 0 // 共享变量

    // 假设我们期望的初始值是0
    oldValue := uint32(0)

    // 使用Compare函数比较当前值与期望值
    if Compare(&value, oldValue) {
        fmt.Println("Value matches the expected old value.")
        // 在这里,你可以执行实际的交换操作,但请注意,
        // 在并发环境中,你应该使用atomic.CompareAndSwapUint32来确保原子性。
        // 例如:
        // newValue := uint32(1)
        // if atomic.CompareAndSwapUint32(&value, oldValue, newValue) {
        //     fmt.Println("CAS succeeded, value is now", newValue)
        // } else {
        //     fmt.Println("CAS failed, value was changed by another goroutine")
        // }
    } else {
        fmt.Println("Value does not match the expected old value.")
    }

    // 修改value的值以演示Compare函数的行为变化
    atomic.AddUint32(&value, 1)

    // 再次比较,此时应该不匹配
    if Compare(&value, oldValue) {
        fmt.Println("Value still matches the expected old value, but this shouldn't happen.")
    } else {
        fmt.Println("Value no longer matches the expected old value.")
    }
}

3.3 解决CAS模式下的ABA问题

3.3.1 什么是ABA问题?

在CAS(Compare-and-Swap)操作中,ABA问题是一个常见的挑战。ABA问题是指一个值原来是A,被另一个线程改为B,然后又被改回A,当前线程使用CAS Compare检查时发现值仍然是A,从而误认为它没有被其他线程修改过。

3.3.2 如何解决?

为了避免ABA问题,可以采取以下策略:

  1. 使用版本号或时间戳:
  • 每当共享变量的值发生变化时,都递增一个与之关联的版本号或时间戳。
  • CAS操作在比较变量值时,同时也要比较版本号或时间戳。
  • 只有当变量值和版本号或时间戳都匹配时,CAS操作才会成功。

  • Java中的java.util.concurrent.atomic包提供了解决ABA问题的工具类。
  • 在Go语言中,通常使用sync/atomic包提供的原子操作来处理并发问题,并引入版本号或时间戳的概念。

那么上面的代码就可以修改成:

type ValueWithVersion struct {
    Value     int32
    Version   int32
}

var sharedValue atomic.Value // 使用atomic.Value来存储ValueWithVersion的指针

func updateValue(newValue, newVersion int32) bool {
    current := sharedValue.Load().(*ValueWithVersion)
    if current.Value == newValue && current.Version == newVersion {
        // CAS操作:只有当前值和版本号都匹配时,才更新值
        newValueWithVersion := &ValueWithVersion{Value: newValue, Version: newVersion + 1}
        sharedValue.Store(newValueWithVersion)
        return true
    }
    return false
}
  1. 不同语言的自带方案:
  • Java中的java.util.concurrent.atomic包提供了解决ABA问题的工具类。
  • 在Go语言中,通常使用sync/atomic包提供的原子操作来处理并发问题,并引入版本号或时间戳的概念。
  1. 引入额外的状态信息:
  • 除了共享变量的值本身,还可以引入额外的状态信息,如是否已被修改过。
  • 线程在进行CAS操作前,会检查这个状态信息,以判断变量是否已被其他线程修改过。

需要注意的是,避免ABA问题通常会增加并发控制的复杂性,并可能带来性能开销。因此,在设计并发系统时,需要仔细权衡ABA问题的潜在影响与避免它所需的成本。在大多数情况下,如果ABA问题不会导致严重的数据不一致或逻辑错误,那么可能不需要专门解决它。

4 总结

在高并发环境下保证数据一致性是一个复杂而关键的问题,涉及到多个层面和策略。除了上面提到的方案外,还有一些常见的方法和原则,用于确保在高并发环境中保持数据一致性:

  1. 事务(Transactions):
  • 使用数据库事务来确保数据操作的原子性、一致性、隔离性和持久性(ACID属性)。
  • 通过锁机制(如行锁、表锁)来避免并发操作导致的冲突。
  1. 分布式锁:
  • 当多个服务或节点需要同时访问共享资源时,使用分布式锁来协调这些访问。
  • 例如,使用Redis的setnx命令或ZooKeeper的分布式锁机制。
  1. 乐观锁与悲观锁:
  • 乐观锁假设冲突不太可能发生,通常在数据更新时检查版本号或时间戳。
  • 悲观锁则假设冲突很可能发生,因此在数据访问时立即加锁。
  1. 数据一致性协议:
  • 使用如Raft、Paxos等分布式一致性算法,确保多个副本之间的数据同步。
  1. 消息队列:
  • 通过消息队列实现数据的异步处理,确保数据按照正确的顺序被处理。
  • 使用消息队列的持久化、重试和顺序保证特性。
  1. CAP定理与BASE理论:
  • 理解CAP定理(一致性、可用性、分区容忍性)的权衡,并根据业务需求选择合适的策略。
  • BASE理论(Basically Available, Soft state, Eventually consistent)提供了一种弱化一致性要求的解决方案。
  1. 缓存一致性:
  • 使用缓存失效策略(如LRU、LFU)和缓存同步机制(如缓存穿透、缓存击穿、缓存雪崩的应对策略),确保缓存与数据库之间的一致性。
  1. 读写分离读写:
  • 使用主从复制、读写分离读写等技术,将读操作和写操作分散到不同的数据库实例上,提高并发处理能力。
  1. 数据校验与重试:
  • 在数据传输和处理过程中加入校验机制,确保数据的完整性和准确性。
  • 对于可能失败的操作,实施重试机制,确保数据最终的一致性。
  1. 监控与告警:
  • 实时监控数据一致性相关的关键指标,如延迟、错误率等。
  • 设置告警阈值,及时发现并处理可能导致数据不一致的问题。

在实际应用中,通常需要结合具体的业务场景和技术栈来选择合适的策略。

© 2023 北京元石科技有限公司 ◎ 京公网安备 11010802042949号