read-after-write consistency 写后读一致性的解决方法
创始人
2024-03-12 16:14:40
0

问题定义

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

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

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

在这里插入图片描述

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

解决方法

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

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

在时间窗口之后,读操作可以转移到从节点。
在这里插入图片描述

代码示例

地址:
https://github.com/mamil/data-demo/tree/main/read-after-write%20consistency

创建数据库的连接

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

  • master的连接可以看这个函数
func initMasterDb() {PDbHost := viper.GetString("MYSQL.HostName")PDbPort := viper.GetString("MYSQL.Port")PDbUser := viper.GetString("MYSQL.UserName")PDbPassWord := viper.GetString("MYSQL.Pwd")PDbName := viper.GetString("MYSQL.DatabaseName")pathWrite := strings.Join([]string{PDbUser, ":", PDbPassWord, "@tcp(", PDbHost, ":", PDbPort, ")/", PDbName, "?charset=utf8&parseTime=true"}, "")db, err := gorm.Open(mysql.Open(pathWrite), &gorm.Config{})if err != nil {panic(err)}sqlDB, _ := db.DB()sqlDB.SetMaxIdleConns(20)sqlDB.SetMaxOpenConns(100)sqlDB.SetConnMaxLifetime(time.Second * 30)_dbMaster = db
}

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

  • 下面是读写分离的连接
func initDatabase() {PDbHost := viper.GetString("MYSQL.HostName")PDbPort := viper.GetString("MYSQL.Port")PDbUser := viper.GetString("MYSQL.UserName")PDbPassWord := viper.GetString("MYSQL.Pwd")PDbName := viper.GetString("MYSQL.DatabaseName")SDbHost := viper.GetString("MYSQLRead.HostName")SDbPort := viper.GetString("MYSQLRead.Port")SDbUser := viper.GetString("MYSQLRead.UserName")SDbPassWord := viper.GetString("MYSQLRead.Pwd")SDbName := viper.GetString("MYSQLRead.DatabaseName")pathWrite := strings.Join([]string{PDbUser, ":", PDbPassWord, "@tcp(", PDbHost, ":", PDbPort, ")/", PDbName, "?charset=utf8&parseTime=true"}, "")pathRead := strings.Join([]string{SDbUser, ":", SDbPassWord, "@tcp(", SDbHost, ":", SDbPort, ")/", SDbName, "?charset=utf8&parseTime=true"}, "")db, err := gorm.Open(mysql.Open(pathWrite), &gorm.Config{})if err != nil {panic(err)}sqlDB, _ := db.DB()sqlDB.SetMaxIdleConns(20)sqlDB.SetMaxOpenConns(100)sqlDB.SetConnMaxLifetime(time.Second * 30)_db = db_ = _db.Use(dbresolver.Register(dbresolver.Config{Sources:  []gorm.Dialector{mysql.Open(pathWrite)}, // 写操作Replicas: []gorm.Dialector{mysql.Open(pathRead)},  // 读操作,headless自动选择Policy:   dbresolver.RandomPolicy{},               // sources/replicas 负载均衡策略}))
}

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

记录写操作

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

type PinningUser struct {gorm.ModelUserId uint
}

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

进行测试

	if rc1Cmd != 0 { // 有写操作的从主数据库读取rcCheck(1, rc1Cmd)log.Infof("rc1 check done")} else if rc2Cmd != 0 { // 全部从从数据库读取rcCheck(2, rc2Cmd)log.Infof("rc2 check done")} else if userCmd != 0 { // 创建用户createUser(userCmd)}

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

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

代码差异

我们先看读写分离的方法

// 全部从节点读取,测试能否复现这个问题
func readUser(userId uint) string {user := User{}if err := _db.Where("id = ?", userId).Find(&user).Error; err != nil {log.Errorf("readUser, id:%v, err:%v", userId, err)return ""} else {return user.Email}}

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

# ./main -c2 100
INFO[0000] register table success
INFO[0000] command: rc1Cmd:0, rc2Cmd:100, userCmd:0
ERRO[0000] rcCheck2, fail, modId:487, modStr:bb6fe76a-6642-11ed-9ace-be2e1b32fdf3, readStr:
ERRO[0000] rcCheck2, fail, modId:780, modStr:bb77e11b-6642-11ed-9ace-be2e1b32fdf3, readStr:
ERRO[0000] rcCheck2, fail, modId:373, modStr:bb872e31-6642-11ed-9ace-be2e1b32fdf3, readStr:
ERRO[0000] rcCheck2, fail, modId:352, modStr:bb87e4f9-6642-11ed-9ace-be2e1b32fdf3, readStr:
INFO[0000] rc2 check done

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


再看Pinning User to Master的方案

// 如果这个用户数据被修改了,就从主节点读取
func readUserRc(userId uint) string {user := User{}pinUser := int64(0)if err := _dbMaster.Model(&PinningUser{}).Where("user_id = ?", userId).Count(&pinUser).Error; err != nil {log.Errorf("readUser, id:%v, err:%v", userId, err)return ""}if pinUser == 0 {if err := _db.Where("id = ?", userId).Find(&user).Error; err != nil {log.Errorf("readUser, from second id:%v, err:%v", userId, err)return ""} else {return user.Email}} else {if err := _dbMaster.Where("id = ?", userId).Find(&user).Error; err != nil {log.Errorf("readUser, from master id:%v, err:%v", userId, err)return ""} else {return user.Email}}
}
  • 先从主节点获取数据,查看这个人有没有写操作。
  • 如果有些操作的话,就从主节点读取数据。
  • 如果没有的话,从从节点读取数据。

测试结果如下:

# ./main -c1 100
INFO[0000] register table success
INFO[0000] command: rc1Cmd:100, rc2Cmd:0, userCmd:0
INFO[0000] rc1 check done# ./main -c1 1000
INFO[0000] register table success
INFO[0000] command: rc1Cmd:1000, rc2Cmd:0, userCmd:0
INFO[0005] rc1 check done

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

总结

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

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

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

相关内容

热门资讯

美国2年期国债收益率上涨15个... 原标题:美国2年期国债收益率上涨15个基点 美国2年期国债收益率上涨15个基...
汽车油箱结构是什么(汽车油箱结... 本篇文章极速百科给大家谈谈汽车油箱结构是什么,以及汽车油箱结构原理图解对应的知识点,希望对各位有所帮...
嵌入式 ADC使用手册完整版 ... 嵌入式 ADC使用手册完整版 (188977万字)💜&#...
重大消息战皇大厅开挂是真的吗... 您好:战皇大厅这款游戏可以开挂,确实是有挂的,需要了解加客服微信【8435338】很多玩家在这款游戏...
盘点十款牵手跑胡子为什么一直... 您好:牵手跑胡子这款游戏可以开挂,确实是有挂的,需要了解加客服微信【8435338】很多玩家在这款游...
senator香烟多少一盒(s... 今天给各位分享senator香烟多少一盒的知识,其中也会对sevebstars香烟进行解释,如果能碰...
终于懂了新荣耀斗牛真的有挂吗... 您好:新荣耀斗牛这款游戏可以开挂,确实是有挂的,需要了解加客服微信8435338】很多玩家在这款游戏...
盘点十款明星麻将到底有没有挂... 您好:明星麻将这款游戏可以开挂,确实是有挂的,需要了解加客服微信【5848499】很多玩家在这款游戏...
总结文章“新道游棋牌有透视挂吗... 您好:新道游棋牌这款游戏可以开挂,确实是有挂的,需要了解加客服微信【7682267】很多玩家在这款游...
终于懂了手机麻将到底有没有挂... 您好:手机麻将这款游戏可以开挂,确实是有挂的,需要了解加客服微信【8435338】很多玩家在这款游戏...