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

问题定义

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

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

  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之类的缓存来进行优化。把写用户记录到缓存中,去除这一次主数据库的读取。

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

相关内容

热门资讯

埃菲尔铁塔在哪 中国仿建埃菲尔... 2019年4月26日,广西南宁市,街头惊现一座巨型山寨版埃菲尔铁塔,高约20米,白色塔身,造型逼真,...
苗族的传统节日 贵州苗族节日有... 【岜沙苗族芦笙节】岜沙,苗语叫“分送”,距从江县城7.5公里,是世界上最崇拜树木并以树为神的枪手部落...
长白山自助游攻略 吉林长白山游... 昨天介绍了西坡的景点详细请看链接:一个人的旅行,据说能看到长白山天池全凭运气,您的运气如何?今日介绍...
北京的名胜古迹 北京最著名的景... 北京从元代开始,逐渐走上帝国首都的道路,先是成为大辽朝五大首都之一的南京城,随着金灭辽,金代从海陵王...
世界上最漂亮的人 世界上最漂亮... 此前在某网上,选出了全球265万颜值姣好的女性。从这些数量庞大的女性群体中,人们投票选出了心目中最美...
应用未安装解决办法 平板应用未... ---IT小技术,每天Get一个小技能!一、前言描述苹果IPad2居然不能安装怎么办?与此IPad不...
脚上的穴位图 脚面经络图对应的... 人体穴位作用图解大全更清晰直观的标注了各个人体穴位的作用,包括头部穴位图、胸部穴位图、背部穴位图、胳...
猫咪吃了塑料袋怎么办 猫咪误食... 你知道吗?塑料袋放久了会长猫哦!要说猫咪对塑料袋的喜爱程度完完全全可以媲美纸箱家里只要一有塑料袋的响...
demo什么意思 demo版本... 618快到了,各位的小金库大概也在准备开闸放水了吧。没有小金库的,也该向老婆撒娇卖萌服个软了,一切只...
埃菲尔铁塔在哪 中国仿建埃菲尔... 2019年4月26日,广西南宁市,街头惊现一座巨型山寨版埃菲尔铁塔,高约20米,白色塔身,造型逼真,...
苗族的传统节日 贵州苗族节日有... 【岜沙苗族芦笙节】岜沙,苗语叫“分送”,距从江县城7.5公里,是世界上最崇拜树木并以树为神的枪手部落...
北京的名胜古迹 北京最著名的景... 北京从元代开始,逐渐走上帝国首都的道路,先是成为大辽朝五大首都之一的南京城,随着金灭辽,金代从海陵王...
长白山自助游攻略 吉林长白山游... 昨天介绍了西坡的景点详细请看链接:一个人的旅行,据说能看到长白山天池全凭运气,您的运气如何?今日介绍...