分享 Data Service 设计分享

zhongfox · 2017年02月20日 · 最后由 huacnlee 回复于 2017年02月20日 · 4030 次阅读

Data Service 是我们团队对前后端数据进行管理的中间层,它是在我们进行前后端分离过程中产生的数据共享实践。Data Service 在团队中的广泛应用,显著提升了系统性能以及大家的开发效率。


项目演进背景

我们团队维护的主要业务是 PC 商品导购平台,该项目在过去几年,经历了从一个很小的 rails 单体项目,演进为一个综合的大型互联网系统。前后端分离正是出现在这个演进过程中的一次拆分。我们在展示层架构上的技术选型是 Node.js KOA 框架,主要期望达到的效果:

  1. 将展示层代码迁移到 node 项目,前端同学直接管理 view 层。
  2. 利用 node.js 扛直接用户压力,系统日常 pv 百万级,大促可能飙到千万级,node.js 的异步 IO 是一个不错的选择。
  3. KOA 利用了 ES6 的 generator, 可以使用同步的语法写出异步的操作,因此 javascript 的回调金字塔也不是问题。

前后端分离

虽然 node ES6 的语言表现力仍然远不及 ruby, 不过在以上的特定领域,这套架构还是取得了预期的效果。技术选型、项目管理都充满了权衡,就看当前阶段你更想要什么。


问题

在前后端分离过程中,我们面临最明显的问题是前端如何获取数据,摸索期我们有尝试过的交互方式有:

  • node.js 直接查询 mysql
  • ruby 提供 http 接口供 node.js 查询
  • 后端提供 thrift 接口供 node.js 查询

nodejs + ORM

先说 node 直接查询 mysql, 我们采用的 ORM 框架sequelize, 我们都很熟悉 Rails 的 ORM 框架 Activerecord, 想着在 node.js 里用 ORM 也是 so easy, 不过实际使用时我们还是遇到了很多问题。

关系型数据库管理的数据关系和程序真实操作的数据结构之间有较大的差异,关系型数据库的数据无法直接表示对象、列表、嵌套等结构,这种差异叫做阻抗失衡(Object-relational impedance mismatch), ORM 正是对阻抗失衡进行结构转译的技术。不过 ORM 框架本身的问题在于性能,在使用 ORM 时,开发同学必须十分了解 mysql 结构,具体字段有没有索引,mysql 数据量有多大,会不会造成慢查询等。然而 node 项目的维护者主要是前端同学,他们在数据库的使用技术上有较大的缺口,最后前端同学要么逼疯要么逼成全栈。

不仅前端不满意,后端同学对 sequelize 的语法也不满意,这主要也是受限于 javascript 和 ruby 的语言表现力的差异,就好比之前你熟悉了用锯子砍树,现在给你一把剪刀,你能满意吗?

http/thrift

也有的数据查询采用 http 交互,ruby 项目变成一个 api 服务。这种实现有以下问题:

  • http 数据传输效率不高,相对于 tpc, 处于应用层的 http 传输作为内部 RPC 有天然的缺陷:文本格式,元数据 (header) 比重太大,需要 dns 等消耗。

  • http 不稳定,容易受网络抖动等影响,对于内部 RPC, 重试,缓存等都需要代码自己考虑。

那么换成thrift如何?thrift 是跨语言的 RPC 框架,采用高效的二进制通讯协议,在应用场景上很适合这种多语言的系统,然后 thrift 的应用也有一些问题:

  • 前端对 thrift 不熟悉,学习成本。如果整个团队都是全栈那就没问题了。
  • thrift 作为面向服务框架,只有 RPC 调用功能,没有提供 RPC 治理功能,如监控,统计等,这些需要自行实现。

因为缺乏标准,一时间,前后端的数据查询进入了群魔乱舞的阶段,mysql, http, thrift 同时存在,项目耦合严重,系统不稳定,频繁告警。


Data Service 设计

上面花了大量的篇幅来说明背景和问题,其实系统的优化往往是这样,如果真的清楚了系统的痛点,那么离解决问题并不远。怕的是没有认识到问题和系统可优化的空间。

结合代码分析

通过分析前后端项目的业务代码,我们发现在涉及数据查询的代码中,大家都在做这样一些判断和操作:

Ruby 项目同学:

  • 通过什么方式提供给前端同学,如果是 http/thrift, 就需要去编写具体的接口,如果是 mysql, 就需要告诉前端同学数据结构,如何查询,有时还得帮前端同学写好 sql.
  • 后端要不要缓存,缓存到哪里,key 是什么,前端同学会进行缓存吗。
  • 前端同学需要什么样的数据格式。
  • 将持久化数据的数据组合为前端需要的格式

Node.js 项目同学:

  • 从哪里获得后端数据,涉及的 ip, port, url, 参数等,是否之前有缓存。有的数据可能还是从多个后端获取。
  • 从后端获取数据是否成功,如果失败了是否要重试,或者是否有备份数据提供展示。
  • 获得的数据是否要缓存,缓存要缓存多久,缓存到哪里,缓存的 key 叫什么。
  • 拿到业务数据后,如何展示。

各项目有大量和业务无直接关系的控制代码。后端同学其实只关心「将持久化数据的数据组合为前端需要的格式」, 而对于维护 view 层的前端同学来说,他们其实只关心「拿到业务数据后,如何展示」, 而在 data service 出现之前,以上逻辑在 node 项目中随处可见,展示层关心了太多数据的逻辑。

结合业务分析

用 rails 的 REST 术语来说,电商平台最常见的 view 形式是 list 页面和 show 页面。list 代表一类资源的列表,如商品列表,评论列表,购买列表。show 页面代码一个具体的资源实例,如 id 为 100 的商品。以上两类抽象数据占据了前后端数据查询的绝大部分。因此我们把前后端的数据抽象为 2 种情况,实例型和关系型:

实例型和关系型

其中实例型数据代表一个资源实体,它和数据表中的一条记录一一对应,在查询时,需要提供一个资源标识和 id 标识,典型的 url 是/:resource_name/:id, 如/products/100;

我们对关系型数据的定义是:除了实例型以外的数据。它代表一类相关资源,可能对应后端的一张表,也可能是多张表中的某些数据。在查询时,只需要提供一个资源标识,典型的 url 是/:resource_name, 如/products, 这样的定义有点类似 nosql 中的聚合关系。

Show me the Code

设:mysql 有 1 个表 products(商品):

id title price(单位是分) ...
123 Ruby 元编程 6880 ...
456 深入浅出 Node.js 6900 ...
... ... ... ...

Rails 中的 ActiveRecord 数据模型:

# app/models/product.rb
class Product < ActiveRecord::Base
  .......
  class << self
    def top_products_by_sales(count)
      # 查询销量最高的count个商品
      ......
    end
  end
end

如果前端展示层需要 2 个页面:

  1. 展示销量 top 10 的商品页面,同时显示所有商品的个数。
  2. 在上面的页面中点击具体商品,进入商品详情页,详情页需要展示商品的 title 和价格,价格展示单位是元。

来看看引入了 Data Service 后,Ruby 和 Node 项目中的需要增加的代码是怎么样的:

ruby 项目代码:

# lib/data_service/product.rb
class DataService::Product < DataService::Base
  self.expire_time = 10.minutes
  self.json_attributes = [:id, :title, :price]

  def price
    Util.fen_to_yuan(model.price)
  end


  class << self
    self.expire_time = 30.minutes
    self.json_attributes = [:top, :count]

    def top
      model.top_products_by_sales(10)
    end
  end
end

node.js 项目代码

let dataService = require('node_data_service')

// 单实例型调用, 返回对象
let product = yield dataService.fetch({model: 'products', ids: 123})
// => {id: 123, title: 'Ruby元编程', price: 68.8}

// 多实例型调用, 返回数组
let products = yield dataService.fetch({model: 'products', ids: [123, 456]})
// => [{id: 123, title: 'Ruby元编程', price: 68.8},
//     {id: 456, title: '深入浅出Node.js', price: 69} ]

// 关系型调用, 以下2种方式完全相同
let topProducts = yield dataService.fetch({model: 'products', ids: 'relation'})
let topProducts = yield dataService.fetch({model: 'products'})
// => {
//      top: [{id: 123, title: 'Ruby元编程', price: 68.8}, {id: 456, title: '深入浅出Node.js', price: 69} ...],
//      count: 23
//    }

以上基本是就是这一次业务需求中,前后端 2 个项目在数据交互上需要添加的全部代码! 在前后端同学商量好需要的数据定义后,后端同学只需要实现数据如何查询和组装,前端同学通过一句简单的 yield 后,可以把所有精力都放到如何展示数据 json 上。他们的代码都不涉及写接口,缓存,查询失败的逻辑,这些都是 Data Service 框架完成的事情。


Data Service 实现

Ruby 语言的 API 设计非常简洁优雅,不过在简洁设计的背后有大量复杂的实现作为支撑。DataService 的目标之一,也是希望能提供一套简单易用的 API, 隐藏大量在数据交互中重复逻辑,让前后端同学各司其职,把精力放在具体业务的实现上。

Data Service 实现的通用功能包括:

  • 采用 redis 作为前后端的统一数据缓存,后端异步更新数据,前端直接查询的是 redis, 高效且稳定。
  • Ruby 项目作为数据维护方,Data Service 维护了统一的 redis 数据结构,这是前后端数据交互的约定结构,对于新的业务逻辑,前后端同学不需要花太多时间去讨论数据结构,只需要明确需要哪些模型,是实例型还是关系型。
  • 提供了统一的容错机制 (http miss 接口), 解决如果缓存里没数据怎么办的问题。

Data Service架构

Ruby 端 DataService::Base 实现

我个人非常喜欢 Ruby 中「类也是对象」的设计,这似乎是一种能治愈强迫症的设计:

class Person
  # 这个作用域定义的方法、配置等, 是针对Person实例, 比如张三, 李四
  class << self
    # 这个作用域定义的方法、配置等, 是针对Person这个类对象的
  end
end

个体和集合这样的关系,在 ORM 领域和 RESTful 领域也有相似的对应关系:

实例对象           <--> 类对象
关系型数据库record <--> 关系型数据库table
show页面          <--> list页面

而在 Data Service 的设计中,我也把所有的数据抽象为了个体/集合这样的关系,用DataService::Base的子类代表一种实例型数据,用DataService::Base的子类的singleton_class代表一种关系型数据:

DataService的实例与关系

在 Data Service 中,实例型数据的定位方式是模型名称+id, 关系型数据的定位方式也是类似,只是 id 是固定的字符串'relation', 关系型数据可以认为是一个特殊的实例型数据,就类似 Ruby 中 Class 是一个特殊的 Ruby 对象。

这样设计的一个好处是统一 API, Ruby 中类也是一个对象,那么类对象和类的实例,会有一些相同的 API, 比如Object#to_s; 而 Data Service 中实例型数据和关系型数据都有设置缓存时间的需求,因此也有相同的 APIexpire_time. 其他相同的 API 还有cache_key, save等:

class DataService::Product < DataService::Base
  ### begin 实例存储 ##################################################

  # 实例缓存存储时间
  self.expire_time = 10.minutes

  # 可选设置, 默认是当前的class对应的顶级ActiveRecord Model, 用于和数据模型关联
  # self.model_class = ::Product

  # 可选设置, 默认是当前的class的小写形式, 最后存到redis的将是 'data_service:#{cache_key}:#{model.id}'
  # self.cache_key = 'product'

  # 前端需要的数据内容, 每一个属性都需要一个对应的方法
  # 方法优先在DataService::Base对象上查找
  # 如果找不到会委托到对应的Activerecord模型上查找
  # 这个例子中, price在下方定义了, id和title将委托到对应的AD模型上
  self.json_attributes = [:id, :title, :price]

  def price
    Util.fen_to_yuan(model.price)
  end

  ## 当调用DataService::Product.new(some_product.id).save 会更新redis实例数据
  ## 将在redis中存储'data_service:product:#{model.id}' 为 {id: ..., title: ..., price: ...}

  ### end 实例存储#####################################################

  class << self
    ### begin 关系存储##################################################

    # 关系型数据有和实例数据同样的设置API:
    self.expire_time = 30.minutes
    self.json_attributes = [:top, :count]
    # 可选设置, 默认是当前的class对应的顶级ActiveRecord Model, 用于和数据模型关联
    # self.model_class = ::Product
    # 可选设置, 默认是当前的class的小写形式, 最后存到redis的将是 'data_service:#{cache_key}:relation'
    # self.cache_key = 'product'

    def top
      model.top_products_by_sales(10)
    end

    ## 当调用DataService::Product.save 更新关系数据
    ## 将在redis中存储'data_service:product:relation' 为 {top: [...], count: ...}

    ### end 关系存储配置#################################################
  end
end

Ruby 普通对象和 Ruby 类对象的统一 API 大部分来自模块Kernel, Data Service 中的实例型和关系型的统一 API 同样来自一个模块DataService::DataApi, 感兴趣的同学可以查阅第一版DataService::Base实现:base.rb

除了统一 API, DataService::DataApi还实现了对声明的json_attributes的实现查找,默认它会在对应的 Activerecord 模型上查找,但是你可以在DataService::Base对象上进行覆盖,比如上例中,实例数据的属性id, title是在 Activerecord 模型的实例上获取的,DataService::Base中的price覆盖了 Activerecord 模型上默认的price方法。


Node.js 端 dataService.fetch 实现

Node.js 端的实现比较简单,npm package dataService 对外 export 唯一方法 fetch, 前端同学通过此方法获取实例型和关系型数据。

该方法首先查询 redis, 前端同学无需关心 redis 的数据解析,fetch 会将数据解析为期望的 json. 如果 redis 里没有数据将会自动调用 Ruby 提供的 miss 接口,不过这些逻辑都是隐藏在 fetch 之下的,前端同学不用关心。

/**
 * 通过传入资源 id 数组, 获取对应的deal的属性
 *
 * @public
 * @param {Object} query 查询信息对象
 *                - model: 字符串, 必要参数, 表示数据模型, 按照约定, 应该全部小写
 *                - ids: 字符串, 数字, 数组, 资源标识
 * @param {Object} 可选参数
 *                - cacheOnly 如果为true, 表示只走缓存, 不会触发miss
 *                - ns 表示namespace
 * @returns {Array/Object} 对于批量查询, 返回数组, 对于单个查询, 返回单个对象
 * @throws {Error} dataService 错误: model为空
 * @throws {Error} dataService 错误: ids为空
 * @throws {RedisError} Redis 超时或者错误
 */
dataService.fetch = function* (query, options) {
......

总结

系统收益

  • 项目解耦

引入数据中间层,Data Service 是 mysql 数据到项目实际需要的数据结构之间的转译,类似单体项目中的 ORM 的作用。

前端 View 层解除了对 mysql 和后端接口的强依赖,即使 mysql/后端服务在短时间内挂掉 (真实发生过多次), Data Service 也是有缓存数据可供前端使用 (View 展示正常,可能数据有点旧).

  • 约定大于配置

Data Service 提供了默认的数据定义,默认的 Data Service 模型和 Activerecord 模型的对应关系,默认的 redis 存储结构,默认的 miss 容错机制。前后开发同学只需要把精力放到每次的业务实现上,不用重复考虑和实现这些通用的功能,这就是约定的好处。

  • 缓存异步刷新,高效且稳定

Data Service 将数据写入缓存和前端读取缓存完全解耦,使得后端可以异步对缓存进行刷新,比如上面的 Product 实例型数据,后端同学可以在Product.after_commit中调用DataService::Product.new(product.id).save进行缓存刷新,也可以通过 rake 定时刷新。

缓存异步刷新的好处使得前端始终有可用的数据,也不会出现雪崩现象,以下是某页面接入 Data Service 后的效果,TP99[^注 1] 显著降低,可用性[^注 2] 明显提高。

今日上新

其他

  • 数据抽象为关系型数据和实例型数据,灵感来源于 Ruby 的「类也是对象」
  • 这是一套小成本的优化:核心代码仅几百行; Redis 在前后端中已经广泛使用,没有引入新的中间件和复杂的概念; 简单统一的 API、出色的性能让系统推广十分顺利。
  • Data Service 项目目标是统一管理 view 层可缓存的数据,大部分系统都是读多写少。对于推荐系统、用户签到、购买等写逻辑的数据流需要另外处理。

[^注 1]: TP99: 关键的性能指标,所有请求响应时间降序排列,去掉 1% 的最高耗时,剩下 99% 的请求中的耗时最大值。

[^注 2]: 可用性:关键的性能指标,HTTP 状态码 4XX, 5XX 以及响应时间超过 5s, 属于不可用,剩下的所有请求属于可用请求。

看起来你们的 DataService 不就是像 Thrift 那样的东西么?只不过没有单独服务跑,而是通过读写的时候放入 Redis。

Update: 哦,不一样...

我的问题是:

  1. 你怎么保障 write 动作的时候能正确的更新(或者设置失效)DataService Redis 的老数据,因为有时候查询关系是复杂的。
  2. 感觉你加了一层 Node.js 似乎和“扛百万压力”(顺便可以说一下是多少机器做到这个量),没直接关系,最后查询都要到 Rails 哪里,唯一区别是你的架构多了一层 Redis 的查询缓存。
  3. 看到有 expire_time 的参数,是每个查询都是固定的缓存一定的有效期么?如果是,那看起来像是 Proxy Cache 可以做的事情呀?你 after_save 那个动作如果只是单纯增对 id 有关的数据过期,Proxy Cache 的方式也是可以搞的。

顺便提醒一个细节,要注意 after_save 并不是数据真的变了,有可能因为其他动作 Rollback 了,应该用 after_commit 来处理缓存过期。

#1 楼 @huacnlee 看了代码,目前的确是用的after_commit 感谢提醒~ 稍后更新文档。

1) 数据更新有 2 种途径:

对于需要实时更新的数据,使用after_commit+sidekiq , 只是稍微进行了封装,调用方式形如:Monitor.watch(Product, SomeSidekiqWorker, :title, :price) 会给 Product 加上after_commit, after_commit中判断如果 title 或 price 有变化,会扔到 sidekiq 中更新. 对于旧数据有一定容忍度的业务,采用 rake 定时更新。

2) 缓存存储前面需要有一个应用服务,两者都可能成为瓶颈,Redis 自然高效,应用服务使用 node 的好处之一在于异步 IO:

let data = yield {
  '商品':  dataService.fetch(...),
  '广告':  dataService.fetch(...),
  '友情链接': dataService.fetch(...),
}

dataService.fetch返回的是 promise, 3 次 IO 的时间为:max(查商品时间,查广告时间,查友链时间)

如果采用阻塞 IO 的实现,时间将是:查商品时间 + 查广告时间 + 查友链时间

3) 缓存时间是各模型必须设置的,是在 redis cluster 中的缓存时间,我们有用 varnish 作为反向代理,主要用于页面缓存。

Data Service 的目的在于管理那些可缓存的数据,Data Service 提供的数据,前端在 Node 中还可能进行加工再展示,这也是之前引入 node 的一个原因--前端希望管理 view 层。

另外不同页面的 Data Service 数据可以在 node 端方便的组合,比如很多页面都需要「友情链接」的数据,在 Data Service 中只需要存一份。

采用 redis 的另一个原因是可以比较容易的进行分布式扩展,可选哨兵 + 主从或者 cluster.

我想请教的也是写上面的问题。比如说你有一个加入购物车的 API, 正常场景就是前端请求,Rails 写入并且返回更新后的购物车。那么在加入了 nodejs 这一层后这类写的流程是怎样的呢?

#2 楼 @zhongfox 我知道 Node.js 并发查询的意义。

我的意思是:

let data = yield {
  '商品':  dataService.fetch(...),
  '广告':  dataService.fetch(...),
  '友情链接': dataService.fetch(...),
}

这段代码 dataService.fetch 实际上后面是从 Redis,还是一个 Rails API + Nginx Proxy Cache(或 Haproxy 之类的 HTTP 缓存实现),效果看起来似乎是一样的。

因为看起来整个核心效率提高的原因碍于 Redis 里面存储的数据缓存(实际上看起来像是 Model 的二级缓存)。


看你们的场景也用法,我设想假如引入 @hoooposecond_level_cache,或许你那层 DataService 到 Redis 的复杂实现都不需要了,Node.js 稍微实现一下这样的流程:

Direct fetch from Cache 
        |
    [Cache] -> <hit> -> Response
        |
      <miss>
        |
    [fetch API] -> Write cache
        |
      Response

#4 楼 @huacnlee 说的是对的

开源的 orm 论成熟度很少有超过 ar 的,所以用 ar 做 data service 是没问题的。

这里的 node koa view 实际上是组装 rpc 服务的,然后返回视图的。用法也是对的,不过明明是 rpc 服务,为啥要叫 data service 呢?

另外你的 cache 不 cache 暴露给 node 部分没啥意义,都放到 rpc 里处理就好

#5 楼 @i5ting 感谢指教. 这里和直接 RPC 还是有点区别,在文中的最后一张图「周内 TP 变化情况」, 该页面是在 22 号之前很多数据是走的 http/thrift RPC, 22 号之前的很多不可用 (超时/5XX) 都是因为 RPC 调用偶尔的失败导致,而 22 后接入了 data service 后,基本保证所有数据在 redis 中都有,基本不会发生 miss 的情况,页面很稳定。

对比 RPC 封装缓存和 data service 采用的异步更新,忘了哪里看来的比喻还挺形象的:

  1. 发现缓存过期时,线程都去获取并更新缓存,容易引发雪崩,5XX 的原因:

    5 个工人(线程)去港口取同样 Key 的货(get),发现货已经过期被扔掉了,这时 5 个工人各自分别去对岸取新货,然后返回

  2. 异步更新数据:

    5 个工人(线程)去港口取同样 Key 的货(get), 货一直存在,即使货过期了也不会扔掉 (很长的有效期). 但港口的货物还是比较新鲜,因为对岸有个家伙在不时更新着货物,这个家伙在 data service 中就是 ruby 端的 after_commit 或者定时 rake

#4 楼 @huacnlee 感谢指教。

学习了一下 second_level_cache, data service 的确做了一些和 second_level_cache 类似的事情,场景的区别主要因为我们前后端项目分离后,Write-Through 在 ruby, Read-Through 需要在 node 端发起. 在单体 ruby 项目中 SecondLevelCache 是个非常不错的选择!

不过有些场景还是不能容忍数据在缓存中 miss 后再去触发生成,这个节点容易造成 5XX, data service 主要就是加强缓存异步刷新,在 redis 数据没有 expire 之前去刷新数据。

#7 楼 @zhongfox second_level_cache 是 Write Through 的,在数据 update 的时候重写 cache 的。

https://github.com/hooopo/second_level_cache/blob/master/lib/second_level_cache/mixin.rb#L76

需要 登录 后方可回复, 如果你还没有账号请 注册新账号