我们之前介绍过分布式事务的解决方案,参考作者这篇《五种分布式事务解决方案(图文总结) 》。
在那篇文章中我们介绍了分布式场景下困扰我们的3个核心需求(CAP):一致性、可用性、分区容错性,以及在实际场景中的业务折衷。
1、一致性(Consistency): 再分布,所有实例节点同一时间看到是相同的数据
2、可用性(Availability): 不管是否成功,确保每一个请求都能接收到响应
3、分区容错性(Partition Tolerance): 系统任意分区后,在网络故障时,仍能操作
而本文我们聚焦高并发下如何保障 Data Consistency(数据一致性)。
这是最经典的场景。支付过程,要先查询买家的账户余额,然后计算商品价格,最后对买家进行进行扣款,像这类的分布式操作,
如果是并发量低的情况下完全没有问题的,但如果是并发扣款,那可能就有一致性问题。。在高并发的分布式业务场景中,类似这种 “查询+修改” 的操作很可能导致数据的不一致性。
同理,买家在电商平台下单,往往会涉及到两个动作,一个是扣库存,第二个是更新订单状态,库存和订单一般属于不同的数据库,需要使用分布式事务保证数据一致性。
跨行转账问题也是一个典型的分布式事务,用户A同学向B同学的账户转账500,要先进行A同学的账户-500,然后B同学的账户+500,既然是 不同的银行,涉及不同的业务平台,为了保证这两个操作步骤的一致,数据一致性方案必然要被引入。
分布式锁的实现,比较常见的方案有3种:
1、基于数据库实现分布式锁
2、基于缓存(Redis或其他类型缓存)实现分布式锁
3、基于Zookeeper实现分布式锁
这3种方案,从实现的复杂度上来看,从1到3难度依次递增。而且并不是每种解决方案都是完美的,它们都有各自的特性,还是需要根据实际的场景进行抉择的。
能力组件 | 实现复杂度 | 性能 | 可靠性 |
---|---|---|---|
数据库 | 高 | 低 | 低 |
缓存 | 中 | 高 | 中 |
zookeeper | 低 | 中 | 高 |
详细可以参考我的这篇文章《分布式锁方案分析》
因为缓存方案是采用频率最高的,所以我们这边对Redis分布式锁进行详细介绍:
相比较于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点。类似Redis可以多集群部署的,解决单点问题。
基于Redis实现的锁机制,主要是依赖redis自身的原子操作,例如:
# 判断是否存在,不存在设值,并提供自动过期时间
SET key value NX PX millisecond
# 删除某个key
DEL key [key …]
NX:只在在键不存在时,才对键进行设置操作,SET key value NX 效果等同于 SETNX key value
PX millisecond:设置键的过期时间为millisecond毫秒,当超过这个时间后,设置的键会自动失效
如果需要把上面的支付业务实现,则需要改写如下:
# 设置账户Id为17124的账号的值为1,如果不存在的情况下,并设置过期时间为500ms
SET pay_id_17124 1 NX PX 500
# 进行删除
DEL pay_id_17124
上述代码示例是指,当redis中不存在pay_key这个键的时候,才会去设置一个pay_key键,键的值为 1,且这个键的存活时间为500ms。
当某个进程设置成功之后,就可以去执行业务逻辑了,等业务逻辑执行完毕之后,再去进行解锁。而解锁之前或者自动过期之前,其他进程是进不来的。
实现锁机制的原理是:这个命令是只有在某个key不存在的时候,才会执行成功。那么当多个进程同时并发的去设置同一个key的时候,就永远只会有一个进程成功。解锁很简单,只需要删除这个key就可以了。
另外,针对redis集群模式的分布式锁,可以采用redis的Redlock机制。
优点:Redis相比于MySQL和Zookeeper性能好,实现起来较为方便。
缺点:通过超时时间来控制锁的失效时间并不是十分的靠谱;这种阻塞的方式实际是一种悲观锁方案,引入额外的 依赖(Redis/Zookeeper/MySQL 等),降低了系统吞吐能力。
对于概率性的不一致的处理,需要乐观锁方案,让你的系统更具健壮性。
分布式CAS(Compare-and-Swap)模式就是一种无锁化思想的应用,它通过无锁算法实现线程间对共享资源的无冲突访问。
CAS模式包含三个基本操作数:内存地址V、旧的预期值A和要修改的新值B。在更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。
我们以 2.1节 的 典型支付场景 作为例子分析(参考下图):
根据上面的CAS原理,在Swap更新余额的时候,加上Compare条件,跟初始读取的余额比较,只有初始余额不变时,才允许Swap成功,这是一种常见的降低读写锁冲突,保证数据一致性的方法。
go 代码示例(使用Baidu Comate AI 生成,已调试):
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.")
}
}
在CAS(Compare-and-Swap)操作中,ABA问题是一个常见的挑战。ABA问题是指一个值原来是A,被另一个线程改为B,然后又被改回A,当前线程使用CAS Compare检查时发现值仍然是A,从而误认为它没有被其他线程修改过。
为了避免ABA问题,可以采取以下策略:
1. 使用版本号或时间戳:
2. 不同语言的自带方案:
那么上面的代码就可以修改成:
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
}
3. 引入额外的状态信息:
需要注意的是,避免ABA问题通常会增加并发控制的复杂性,并可能带来性能开销。因此,在设计并发系统时,需要仔细权衡ABA问题的潜在影响与避免它所需的成本。在大多数情况下,如果ABA问题不会导致严重的数据不一致或逻辑错误,那么可能不需要专门解决它。
在高并发环境下保证数据一致性是一个复杂而关键的问题,涉及到多个层面和策略。
除了上面提到的方案外,还有一些常见的方法和原则,用于确保在高并发环境中保持数据一致性:
在实际应用中,通常需要结合具体的业务场景和技术栈来选择合适的策略。