• read-after-write consistency 写后读一致性的解决方法


    问题定义

    写后读一致即写完数据之后马上读,直接能读到新的数据,而不是老的数据。

    导致这个问题主要是数据库之间的同步延时。这里只讨论一主多从的情况。 如下图:

    1. 用户添加新评论
    2. 用户刷新,读请求到从节点1,此时从节点还没有主从复制完成,用户看不到自己的评论
    3. 用户刷新,读请求到从节点1,此时从节点主从复制完成,用户可以看见自己的评论
    4. 用户刷新,读请求到从节点2,此时从节点还没有主从复制完成,用户看不到自己的评论

    这种情况就会出现很诡异的情况,自己的评论时有时无的。我们预期是一直能看到最新的评论。

    解决方法

    解决方法之一是把最近有写入操作的用户的读取操作路由到数据库主节点(Pinning User to Master)。其他用户的读取操作还是走从节点。

    当用户写入之后的特定的时间窗口内,将用户的读操作固定到主节点,这样读取和写入都转移到了主节点,这意味着读取将从始终具有最新数据副本的数据节点进行,因此可以实现读写一致性。

    在时间窗口之后,读操作可以转移到从节点。

    代码示例

    地址: github.com/mamil/data-…

    创建数据库的连接

    这里创建2种连接,一个直接和master相连,另一个使用读写分离的方式连接。

    • master的连接可以看这个函数
    1. func initMasterDb() {
    2. PDbHost := viper.GetString("MYSQL.HostName")
    3. PDbPort := viper.GetString("MYSQL.Port")
    4. PDbUser := viper.GetString("MYSQL.UserName")
    5. PDbPassWord := viper.GetString("MYSQL.Pwd")
    6. PDbName := viper.GetString("MYSQL.DatabaseName")
    7. pathWrite := strings.Join([]string{PDbUser, ":", PDbPassWord, "@tcp(", PDbHost, ":", PDbPort, ")/", PDbName, "?charset=utf8&parseTime=true"}, "")
    8. db, err := gorm.Open(mysql.Open(pathWrite), &gorm.Config{})
    9. if err != nil {
    10. panic(err)
    11. }
    12. sqlDB, _ := db.DB()
    13. sqlDB.SetMaxIdleConns(20)
    14. sqlDB.SetMaxOpenConns(100)
    15. sqlDB.SetConnMaxLifetime(time.Second * 30)
    16. _dbMaster = db
    17. }
    18. 复制代码

    直接连接到主数据库,用户有写入操作的时候就用这个连接进行读写

    • 下面是读写分离的连接
    1. func initDatabase() {
    2. PDbHost := viper.GetString("MYSQL.HostName")
    3. PDbPort := viper.GetString("MYSQL.Port")
    4. PDbUser := viper.GetString("MYSQL.UserName")
    5. PDbPassWord := viper.GetString("MYSQL.Pwd")
    6. PDbName := viper.GetString("MYSQL.DatabaseName")
    7. SDbHost := viper.GetString("MYSQLRead.HostName")
    8. SDbPort := viper.GetString("MYSQLRead.Port")
    9. SDbUser := viper.GetString("MYSQLRead.UserName")
    10. SDbPassWord := viper.GetString("MYSQLRead.Pwd")
    11. SDbName := viper.GetString("MYSQLRead.DatabaseName")
    12. pathWrite := strings.Join([]string{PDbUser, ":", PDbPassWord, "@tcp(", PDbHost, ":", PDbPort, ")/", PDbName, "?charset=utf8&parseTime=true"}, "")
    13. pathRead := strings.Join([]string{SDbUser, ":", SDbPassWord, "@tcp(", SDbHost, ":", SDbPort, ")/", SDbName, "?charset=utf8&parseTime=true"}, "")
    14. db, err := gorm.Open(mysql.Open(pathWrite), &gorm.Config{})
    15. if err != nil {
    16. panic(err)
    17. }
    18. sqlDB, _ := db.DB()
    19. sqlDB.SetMaxIdleConns(20)
    20. sqlDB.SetMaxOpenConns(100)
    21. sqlDB.SetConnMaxLifetime(time.Second * 30)
    22. _db = db
    23. _ = _db.Use(dbresolver.
    24. Register(dbresolver.Config{
    25. Sources: []gorm.Dialector{mysql.Open(pathWrite)}, // 写操作
    26. Replicas: []gorm.Dialector{mysql.Open(pathRead)}, // 读操作,headless自动选择
    27. Policy: dbresolver.RandomPolicy{}, // sources/replicas 负载均衡策略
    28. }))
    29. }
    30. 复制代码

    把写操作指定到主数据库,都操作指定到从数据库。

    记录写操作

    为了不引入其他复杂的,我们这里还是在mysql中记录哪个用户进行了写操作。 添加下面这个数据结果记录写操作的用户:

    1. type PinningUser struct {
    2.     gorm.Model
    3.     UserId uint
    4. }
    5. 复制代码

    结构很简单,就是记录了一些用户的id。 每当用户进行写操作的时候,就把用户id记录下来。超出指定时间之后,就删除这个记录。定时删除这部分代码没有在此进行实现。

    进行测试

    1. if rc1Cmd != 0 { // 有写操作的从主数据库读取
    2. rcCheck(1, rc1Cmd)
    3. log.Infof("rc1 check done")
    4. } else if rc2Cmd != 0 { // 全部从从数据库读取
    5. rcCheck(2, rc2Cmd)
    6. log.Infof("rc2 check done")
    7. } else if userCmd != 0 { // 创建用户
    8. createUser(userCmd)
    9. }
    10. 复制代码

    这里会根据命令行参数进行操作选择

    • 创建用户使用
      • ./main -u 1000
    • 测试只进行读写分离
      • ./main -c2 100
    • 使用Pinning User to Master这个方法
      • ./main -c1 1000

    代码差异

    我们先看读写分离的方法

    1. // 全部从节点读取,测试能否复现这个问题
    2. func readUser(userId uint) string {
    3. user := User{}
    4. if err := _db.Where("id = ?", userId).
    5. Find(&user).Error; err != nil {
    6. log.Errorf("readUser, id:%v, err:%v", userId, err)
    7. return ""
    8. } else {
    9. return user.Email
    10. }
    11. }
    12. 复制代码

    写完数据直接从从节点进行数据读取,这里可能会存在数据没同步到从节点的情况,从而导致读取不到数据。 下面的测试结果也可以印证这个问题

    1. # ./main -c2 100
    2. INFO[0000] register table success
    3. INFO[0000] command: rc1Cmd:0, rc2Cmd:100, userCmd:0
    4. ERRO[0000] rcCheck2, fail, modId:487, modStr:bb6fe76a-6642-11ed-9ace-be2e1b32fdf3, readStr:
    5. ERRO[0000] rcCheck2, fail, modId:780, modStr:bb77e11b-6642-11ed-9ace-be2e1b32fdf3, readStr:
    6. ERRO[0000] rcCheck2, fail, modId:373, modStr:bb872e31-6642-11ed-9ace-be2e1b32fdf3, readStr:
    7. ERRO[0000] rcCheck2, fail, modId:352, modStr:bb87e4f9-6642-11ed-9ace-be2e1b32fdf3, readStr:
    8. INFO[0000] rc2 check done
    9. 复制代码

    这100次测试里面有4次失败了。


    再看Pinning User to Master的方案

    1. // 如果这个用户数据被修改了,就从主节点读取
    2. func readUserRc(userId uint) string {
    3. user := User{}
    4. pinUser := int64(0)
    5. if err := _dbMaster.Model(&PinningUser{}).
    6. Where("user_id = ?", userId).
    7. Count(&pinUser).Error; err != nil {
    8. log.Errorf("readUser, id:%v, err:%v", userId, err)
    9. return ""
    10. }
    11. if pinUser == 0 {
    12. if err := _db.Where("id = ?", userId).
    13. Find(&user).Error; err != nil {
    14. log.Errorf("readUser, from second id:%v, err:%v", userId, err)
    15. return ""
    16. } else {
    17. return user.Email
    18. }
    19. } else {
    20. if err := _dbMaster.Where("id = ?", userId).
    21. Find(&user).Error; err != nil {
    22. log.Errorf("readUser, from master id:%v, err:%v", userId, err)
    23. return ""
    24. } else {
    25. return user.Email
    26. }
    27. }
    28. }
    29. 复制代码
    • 先从主节点获取数据,查看这个人有没有写操作。
    • 如果有些操作的话,就从主节点读取数据。
    • 如果没有的话,从从节点读取数据。

    测试结果如下:

    1. # ./main -c1 100
    2. INFO[0000] register table success
    3. INFO[0000] command: rc1Cmd:100, rc2Cmd:0, userCmd:0
    4. INFO[0000] rc1 check done
    5. # ./main -c1 1000
    6. INFO[0000] register table success
    7. INFO[0000] command: rc1Cmd:1000, rc2Cmd:0, userCmd:0
    8. INFO[0005] rc1 check done
    9. 复制代码

    可以看到,每次写完马上读,都能获取到数据。

    总结

    从实验结果我们可以看到,Pinning User to Master这个方案确实可以解决主从节点同步不及时这个问题。

    现在的代码中,有写操作的用户都存在数据库中。这样会导致每次读数据,至少会去主数据库读一次。主数据库的读取压力会增大。 我们可以使用redis之类的缓存来进行优化。把写用户记录到缓存中,去除这一次主数据库的读取。

    还有一个问题是,此方案下针对这个写数据用户的所有读取都会走主数据库。但有些数据可以容忍同步不及时,读到老数据。 这种情况下,我们可以对数据进行路径区分。如果这次读取的数据是容忍不及时的,那还是从从数据库读取。要读取最新数据时,才走主节点。 这样可以把部分读取压力还给从数据库。但这种做法把复杂度放到了业务代码这里,业务代码需要对数据进行分类处理。这要看实际业务逻辑中的选择了。

  • 相关阅读:
    ElasticSearch 实现 全文检索 支持(PDF、TXT、Word、HTML等文件)通过 ingest-attachment 插件实现 文档的检索
    node的md5加密方式
    【WLAN】【调试】Windows系统下,如何查看无线(WLAN)相关日志
    实验六 设计模式
    (二)初识Vue
    R语言ggplot2可视化:使用patchwork包将多个ggplot2可视化结果组合起来、两个可视化图像纵向组合之后再和另外一个可视化结果横向组合
    尚硅谷Docker核心技术
    Symfony 控制台命令教程
    基于Redis实现分布式锁(执行流程)
    SpringBoot中CommandLineRunner的使用
  • 原文地址:https://blog.csdn.net/m0_71777195/article/details/128159552