优秀的数据同步方案如何设计

应用开发中,为了提升查询性能或者做服务降级方案时,我们会使用缓存作为解决方案,像分布式缓存方案,比如 Redis、Memcache等;本地缓存方案,比如 Guava、Caffeine等。如果仅仅对当前服务的执行结果的缓存,用于下次相同查询时加快查询效率来说,还相对简单一点。只需要将查询条件作为key,返回的结果作为 value 即可实现,复杂一点会加上缓存失效机制等。

但还有一种可能缓存,可能是需要进行数据的同步的操作的。比如笔者之前做过的用户权限中心,由于对响应实时性方面有很大的要求,虽然使用了异步非阻塞编程方式以提高性能,但如果涉及到数据库的操作,其实性能并不能达到目标值。由于权限的相关配置项通过字节估算,对资源消耗并不算大,因而,笔者考虑使用本地缓存方案实现。

同步方案

做数据同步需要考虑同步方案和数据格式。同步方案常见有主动同步(启动初始化、定时任务)和被动同步(消息通知、回调)两种模式。应用一般会在启动的时候初始化一份基准数据,之后的数据更新都基于这份基准数据进行修改。对数据实时性要求不高的场景,可以通过定时任务方式主动拉取数据,在这种方式中存在全量和增量两种模式。全量是最简单的方案,只需要对原先的缓存进行清空操作,填充最新的数据即可,适合数据量比较小的场景。增量方式相对来说比较复杂,需要依照不同的更新维度做相应的修改。还是拿权限例子来说,一般存在Tenant、AppId、 User、Role、Group、Resource等内容,这里存在层级关系,{User、Role、Group、Resource} 存在于 AppId 下,AppId 又同时存在于 Tenant 中,其中广义上来说 API、Tag、Menu 都是属于 Resource范畴,具体设计这里不进行展开,那么缓存格式可以是这样的:

1
Tenant -> Appid -> Method -> Path -> UserId -> RoleId

在用户登录的时候,会携带 tenant、appid、user、role 等信息,同时,当前请求的 Method 和 Path 也是可以知晓的。假设用户在配置请求路径 Path 的时候配置错误了,现在需要在后台进行修改,修改之后就会进行数据的同步,我们先不关心用哪种方式触发同步,我们去修改缓存的时候,需要从左到右一层层进行判断,进行修改,这样还不是最麻烦的,麻烦的是上面的每一层级都是一个可以变化的单元,都可能存在新增、修改和删除的情况,是不是想想就会觉得头大了呢。那么有哪些解决方案可以供参考:

  • 全量同步,简单粗暴且高效,但不适合数据量大且获取更新数据比较复杂麻烦的场景
  • 拆分多个缓存,例如 Tenant -> Appid -> Method -> Path -> RoleIdTenant -> Appid -> Method -> Path -> UserId(这里只是举例说明,实际并非如此)
  • 简化操作,一般缓存都是存在增删改的操作,这三者中改操作往往是最复杂的一种,如果只有增删会简单很多

再回过来讲一下消息通知的同步方式,消息通知存在 RabbitMQ、RocketMQ、Kafka 等消息中间件解决方案。在一致性方面要求高的场景,可以使用 RabbitMQ 和 RocketMQ,能确保数据量比较大的场景推荐使用 Kafka 方案,毕竟 Kafka 是为大数据而生的。使用消息通知的方式就需要引用消息中间,相对 API 方式来说比较笨重且引入了一个不稳定因素,对于小项目来说得不偿失,同时,如果是公司外部应用,不会提供消息中间件作为数据同步方案。

接着说说回调,这种方式被广泛用于对外业务中,HTTP 或者 HTTPS 方式比较轻量级、接受度高,当然回调这种概念不局限于通讯协议方式,RPC 方式也是可以的。回调方式与消息通知方式进行对比的话,回调需要自行实现幂等和重试机制,在编码方面需要投入更多,这也是大家为什么异步的场景青睐消息队列的原因。

数据格式

数据格式需要结合同步方案和业务要求。如果是增量的方式,需要考虑修改前与修改之后,比如这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[
{
"id":"UUID",
"op":"U", // 操作,U、D
"t":1590730661263, // 时间戳
"prev":{
"id":"XXX", // 更新前ID
"name":"zhansan", // 更新前名称
"time":"1590730661124" // 更新前更新时间
},
"cur":{
"id":"XXX", // 更新后ID
"name":"lisi", // 更新后名称
"time":"1590730661263" // 更新后时间
}
},
{
......
}
]

全量方式则不需要这么复杂,只要最新结果集即可。同步方案的不同也会存在字段的考量,一般会从幂等性、数据一致性、服务稳定性、可用性、实时性等方面出发。一般我建议:

  • 字段尽可能短
  • 必须有id和时间戳信息
  • Type 类型字段值,尽可能使用 Int 类型或者短字符串映射,例如上面的op字段使用短字符串方式

一些建议

正向不行,可以试试反向。在设计缓存结构时候,由于人的大脑擅长正向思维,可能设计的结果并不特别的理想(在查询和更新性能方面),这个时候可以考虑反向试试,可能会豁然开朗。Tenant -> Appid -> Method -> Path -> UserId 数据格式,在某些场景不如 UserId -> Method -> Path -> Appid -> Tenant

稳定节点在前,多变的在后。数据量少在前,数据量多在后。 上面的例子中,Tenant 相对比较稳定,变更的比较少且数据量相对于 UserId 肯定比较少。这样在修改或者查找的时候,性能相对好。

空间与时间互换。 这个想必大家经常听到,时间换空间或者空间换时间。对于性能有要求的业务场景,通过冗余缓存方案可以提高查询性能;在资源紧张的场景但对时间有包容性,那适当在实时性方面进行取舍。

不要忽视数据提供方的性能问题。 实时性不仅仅依赖于需要数据的那方或者中间件,数据提供方也是可能存在性能瓶颈的。如果数据的数据格式要求特别变态,需要数据提供方联表查询 3 张表以上,性能可想而知,所以同步的数据要进行取舍,从而节省网络带宽和IO,提升性能。

迹_Jason wechat
分享快乐,感受科技的温度
坚持原创技术分享,您的支持将鼓励我继续创作!