likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 9

问题定义

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

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

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

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

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

解决方法

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

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

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

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

代码示例

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

创建数据库的连接

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

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