李锋镝的博客

  • 首页
  • 时间轴
  • 说说
  • 左邻右舍
  • 博友圈
  • 关于我
    • 关于我
    • 另一个网站
    • 我的导航站
    • 网站地图
    • 赞助
  • 留言
  • 走心评论
  • 系列文章
  • Now
  • 每日心情
  • 🚇开往
Destiny
自是人生长恨水长东
  1. 首页
  2. 转载
  3. 技术
  4. 正文

高并发下的数据一致性保障

2024年4月2日 约 3,295 字11 分钟 26点热度 0人点赞 0条评论
本文最后更新于 2024年4月2日,距今已 860 天,其中的信息可能已经发生变化,请注意甄别。

1 背景

分布式场景下困扰我们的3个核心需求(CAP):一致性、可用性、分区容错性,以及在实际场景中的业务折衷。
1、一致性(Consistency): 再分布,所有实例节点同一时间看到是相同的数据
2、可用性(Availability): 不管是否成功,确保每一个请求都能接收到响应
3、分区容错性(Partition Tolerance): 系统任意分区后,在网络故障时,仍能操作
自动草稿

而本文我们聚焦高并发下如何保障 Data Consistency(数据一致性)。

2 分布式常见一致性问题

2.1 典型支付场景

这是最经典的场景。支付过程,要先查询买家的账户余额,然后计算商品价格,最后对买家进行进行扣款,像这类的分布式操作,
如果是并发量低的情况下完全没有问题的,但如果是并发扣款,那可能就有一致性问题。。在高并发的分布式业务场景中,类似这种 “查询 修改” 的操作很可能导致数据的不一致性。
自动草稿

2.2 在线下单场景

同理,买家在电商平台下单,往往会涉及到两个动作,一个是扣库存,第二个是更新订单状态,库存和订单一般属于不同的数据库,需要使用分布式事务保证数据一致性。
自动草稿

2.3 跨行转账场景

跨行转账问题也是一个典型的分布式事务,用户A同学向B同学的账户转账500,要先进行A同学的账户-500,然后B同学的账户 500,既然是 不同的银行,涉及不同的业务平台,为了保证这两个操作步骤的一致,数据一致性方案必然要被引入。
自动草稿

3 一致性解决方案

3.1 分布式锁

分布式锁的实现,比较常见的方案有3种:
1、基于数据库实现分布式锁
2、基于缓存(Redis或其他类型缓存)实现分布式锁
3、基于Zookeeper实现分布式锁

这3种方案,从实现的复杂度上来看,从1到3难度依次递增。而且并不是每种解决方案都是完美的,它们都有各自的特性,还是需要根据实际的场景进行抉择的。

能力组件 实现复杂度 性能 可靠性
数据库 高 低 低
缓存 中 高 中
zookeeper 低 中 高

因为缓存方案是采用频率最高的,所以我们这边对Redis分布式锁进行详细介绍:

3.1.1 基于缓存实现分布式锁

相比较于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点。类似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机制。

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 代码示例(使用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.")  
  }  
}

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操作才会成功。

2. 不同语言的自带方案:

  • 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  
} 

3. 引入额外的状态信息:

  • 除了共享变量的值本身,还可以引入额外的状态信息,如是否已被修改过。
  • 线程在进行CAS操作前,会检查这个状态信息,以判断变量是否已被其他线程修改过。

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

4 总结

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

  1. 事务(Transactions):

    • 使用数据库事务来确保数据操作的原子性、一致性、隔离性和持久性(ACID属性)。
    • 通过锁机制(如行锁、表锁)来避免并发操作导致的冲突。
  2. 分布式锁:

    • 当多个服务或节点需要同时访问共享资源时,使用分布式锁来协调这些访问。
    • 例如,使用Redis的setnx命令或ZooKeeper的分布式锁机制。
  3. 乐观锁与悲观锁:

    • 乐观锁假设冲突不太可能发生,通常在数据更新时检查版本号或时间戳。
    • 悲观锁则假设冲突很可能发生,因此在数据访问时立即加锁。
  4. 数据一致性协议:

    • 使用如Raft、Paxos等分布式一致性算法,确保多个副本之间的数据同步。
  5. 消息队列:

    • 通过消息队列实现数据的异步处理,确保数据按照正确的顺序被处理。
    • 使用消息队列的持久化、重试和顺序保证特性。
  6. CAP定理与BASE理论:

    • 理解CAP定理(一致性、可用性、分区容忍性)的权衡,并根据业务需求选择合适的策略。
    • BASE理论(Basically Available, Soft state, Eventually consistent)提供了一种弱化一致性要求的解决方案。
  7. 缓存一致性:

    • 使用缓存失效策略(如LRU、LFU)和缓存同步机制(如缓存穿透、缓存击穿、缓存雪崩的应对策略),确保缓存与数据库之间的一致性。
  8. 读写分离读写:

    • 使用主从复制、读写分离读写等技术,将读操作和写操作分散到不同的数据库实例上,提高并发处理能力。
  9. 数据校验与重试:

    • 在数据传输和处理过程中加入校验机制,确保数据的完整性和准确性。
    • 对于可能失败的操作,实施重试机制,确保数据最终的一致性。
  10. 监控与告警:

    • 实时监控数据一致性相关的关键指标,如延迟、错误率等。
    • 设置告警阈值,及时发现并处理可能导致数据不一致的问题。

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

 
除非注明,否则均为李锋镝的博客原创文章,转载必须以链接形式标明本文链接

本文链接:https://www.lifengdi.com/transport/4088

推荐阅读

  • ThreadLocal如何解决内存泄漏问题
  • SpringBoot定时任务 - 经典定时任务设计:时间轮(Timing Wheel)案例和原理
  • MybatisCodeHelperPro激活
  • @Resource 和 @Autowired 的区别
  • C# 11 的这个新特性,我愿称之最强!
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可
标签: 暂无
最后更新:2024年4月2日

岁月同一天 8 月 10 日

回望过去的今天,你在写什么

  • 4 年前 2022年8月10日
    Linux开启FTP

    1、安装 vsftpd sudo yum install -y vsftpd 2、设置 vsftpd 开机自启动 sud…

相关文章
  • Spring中@Autowired和@Resource的区别详解2021年1月15日
  • Dapr 知多少 | 分布式应用运行时2021年2月26日
  • MySQL数据库详解——执行SQL更新时,其底层经历了哪些操作?2019年12月22日
  • Chrome等浏览器下出现net::ERR_BLOCKED_BY_CLIENT的解决办法2021年2月17日
  • Java之五种遍历Map集合的方式2021年2月19日

李锋镝

既然选择了远方,便只顾风雨兼程。

打赏 点赞
< 上一篇
下一篇 >
1234567891112131415161718192021222324252627282930313233343536373839404142434446474849505152535455575859606162636465666769727476777879808182858687909293949596979899
取消回复
…

文章评论

还没有评论,快来抢沙发吧~

生命中所有的灿烂,终要用寂寞偿还。

听点儿音乐吧 朋友~
文章目录
最新 热点 随机
最新 热点 随机
Kratos+ v1.1.14版本更新说明 Spring Boot 指定外部配置文件的方式 Spring Boot 配置加载优先级总结 Claude Fable 5(claude-fable-5)深度详解 如何通过命令查看Java应用内存中对象数量 关于服务的探活端口和业务端口不一致有什么问题
给主题增加了Now、每日心情、年度回顾、岁月同一天、随机漫步等功能AI时代,个人技术博客的出路在哪里?增加了两套复古皮肤-牛皮纸、千禧网页这个域名注册整整十年了,十年时间,真快啊WordPress实现用户评论等级排行榜插件WordPress网站换了个字体,差点儿把样式换崩了
内存屏障浅析 深入了解PostgreSQL TestNG基本注解 缓存架构实战指南:6大核心缓存技术深度解析与落地方案 Tomcat7.0配置CORS(跨域资源共享) 使用AbstractRoutingDataSource动态切换数据源
最近评论
Hary 发布于 11 小时前(08月09日) 我都想用了,但是是ty,转换有点麻烦
老张博客 发布于 2 天前(08月08日) 做的越来越精美了,好看。
Huo 发布于 2 天前(08月07日) 挺有特色的主题,还是感觉 WP 的确是强大
李锋镝 发布于 3 天前(08月07日) 没理解你想说啥
aboss 发布于 3 天前(08月07日) 你的后台web-login?
标签聚合
Claude AI WordPress 架构 SpringBoot AI编程 docker Spring 日常 ElasticSearch IDEA JVM 多线程 SQL MySQL JAVA 分布式 Redis K8s 数据库
友情链接
  • 老张博客
  • Mr.Sun的博客
  • 知向前端
  • 瓦匠个人小站
  • 皮皮社
  • 林羽凡
  • 志文工作室
  • 旧时繁华
  • 韩情脉脉
  • 拾趣博客导航
  • 临窗旋墨
  • 懋和道人
  • 哥斯拉
  • Honesty
  • Blogs·CN
  • 韩小韩博客
  • 搬砖日记
  • 风渡言
  • 彬红茶日记

COPYRIGHT © 2026 lifengdi.com. ALL RIGHTS RESERVED.

正在博友圈履约中

域名年龄

Theme Kratos+ By Dylan Li

津ICP备2024022503号-3

京公网安备11011502039375号