写后读一致即写完数据之后马上读,直接能读到新的数据,而不是老的数据。
导致这个问题主要是数据库之间的同步延时。这里只讨论一主多从的情况。
如下图:
这种情况就会出现很诡异的情况,自己的评论时有时无的。我们预期是一直能看到最新的评论。
解决方法之一是把最近有写入操作的用户的读取操作路由到数据库主节点(Pinning User to Master)。其他用户的读取操作还是走从节点。
当用户写入之后的特定的时间窗口内,将用户的读操作固定到主节点,这样读取和写入都转移到了主节点,这意味着读取将从始终具有最新数据副本的数据节点进行,因此可以实现读写一致性。
在时间窗口之后,读操作可以转移到从节点。
地址:
https://github.com/mamil/data-demo/tree/main/read-after-write%20consistency
这里创建2种连接,一个直接和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.Model
UserId 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
./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之类的缓存来进行优化。把写用户记录到缓存中,去除这一次主数据库的读取。
还有一个问题是,此方案下针对这个写数据用户的所有读取都会走主数据库。但有些数据可以容忍同步不及时,读到老数据。
这种情况下,我们可以对数据进行路径区分。如果这次读取的数据是容忍不及时的,那还是从从数据库读取。要读取最新数据时,才走主节点。
这样可以把部分读取压力还给从数据库。但这种做法把复杂度放到了业务代码这里,业务代码需要对数据进行分类处理。这要看实际业务逻辑中的选择了。