业务背景

自2019年业务启动以来,OPPO 运动健康的业务功能和数据规模已经发展到一定阶段,但其数据相对封闭,提供给用户的玩法也较为单一。随着穿戴设备使用场景的扩展,接入的外部渠道越来越多,亟需一套标准开放平台,赋能合作方共同建设服务体系,满足用户千变万化的需求,对外输出价值,实现共赢。

需求拆解

开放平台需要具备对外授权,并且对请求快速鉴权的能力,因此需求可以拆解如下:

  • 授权。签发一个代表一系列权限集合的访问令牌。
  • 鉴权。对外部请求进行权限校验,拒绝越权访问。
  • 管理。实现一个简易后台,能对权限作实时调整。

    设计开发

    授权 - 行业方案 OAuth

    Image_20220510155303.png
    目前业界通用的授权鉴权使用 OAuth 2.0 授权码的方式来实现。具体流程如下:
    1. 三方提供一个url,放在合适的位置,然后用户点击后调转到平台授权页。比如:

    https://xxx.wanyol.com/open/v1/oauth/authorize?response_type=code&client_id=CLIENT_ID& redirect_uri=CALLBACK_URL&scope=read

其中 response_type=code 表示以授权授权码的方式进行授权,client_id 是平台给三方的身份信息,用于唯一标识三方。redirect_uri 用户授权后平台回调地址。scope标识请求的授权范围。
2. 用户进入平台授权页后,需要先登录平台账号,然后选择授予三方的权限。平台服务端生成授权码 code,并通过回调地址 redirect_uri 告知三方。比如

https://open.gotoxxx.com/callback?code=AUTHORIZATION_CODE

  1. 三方拿到授权码code 后,请求平台的服务端获取token 接口,比如

    curl -X POST ‘https://xxx.wanyol.com/open/v1/oauth/token‘ -d ‘client_id=‘ -d ‘client_secret=‘ -d ‘grant_type=authorization_code’ -d ‘authorization_code=‘ -d ‘https://open.gotoxx.com/callback

  2. 三方获取 token 后,将 token 放入请求头中,请求平台数据

    授权 - 运动健康方案

    OAuth 2.0 授权码方式是经过广泛验证的安全方案,这里我们结合运动健康的实际情况,采用 Oauth2.0 规范 + H5授权 + 云云对接的方式实现授权。
    Image_20220509143723.png
    下面根据三方实际接入和使用场景来阐述具体的方案设计:
    1. 三方申请接入开发平台,需要提交必要资料,审核通过后发放 client_id 和 client_secret。
    client_id 是三方的身份标识,client_secret 是三方证明“我是我”的凭证,可以应用与后面的生成 accessToken 接口。需要注意的是三方提供的回调地址 redirect_uri 必须和后面的生成 accessToken 请求入参同源,这是为了防止攻击者篡改回调地址,企图让用户登录后生成的授权码返回到攻击者网站中。

  3. 三方应用在合适的地方,配置开放平台的H5授权页面。用户登录成功并勾选授权范围后,向三方返回授权码。
    我们的方案中,为了让权限管控更灵活,采用了三级权限的设计,即系统权限、三方应用权限、三方用户权限,范围逐级缩小。系统权限,即开放平台所有可以对外提供的权限,比如读写用户基本信息的权限,读写用户运动数据的权限。三方应用授权是系统权限的子集,指的是具体的三方授予哪些权限。而三方用户权限是三方应用权限的子集,指用户勾选了哪些权限给三方应用。
    授权接口主要入参有,三方clientId,redirectUrl,以及勾选的数据权限 scopes,以及用户登录后的账号ssoid。其中 clientId 需要校验是否在开发平台备案,不存在不允许授权。redirectUrl 表示授权后的回调地址。正如上一步提及到的,此处必须做同源校验。还有一点需要声明,推荐三方应用的的回调地址使用 state 参数,state 参数是为了保证申请 code 的设备和使用 code 的设备的一致而存在的。开发平台回原封不动的返回。state规则生成复杂一些,攻击者就无从下手了。为了保证安全,授权码 authorizationCode 时效性只有五分钟。

  4. 三方调用接口用授权码(Authorization Code)获得 accessToken,同时获得一个用于刷新 accessToken 的 refreshToken。
    特别注意,这个接口包含用授权码置换 accessToken 以及使用 refreshToken 刷新 accessToken。clientSecret 是必填的,也要和开放平台的 clientSecret 一致。出于安全性考虑,accessToken 时效为24小时,refreshToken 时效时间为30天(每次刷新时都会更新 refreshToken 为30天)。其中 accessToken 和 refreshToken 都包含一些特定的信息,通过 AES256 GCM 加密和 BASE64 生成的。

  5. 内容接口鉴权
    三方获得 accessToken 后,需要在请求平台数据接口时在请求头中设置 accessToken。开放平台会进行以下校验:

  6. accessToken 是否存在

  7. accessToken 是否失效
  8. 访问的接口是否在用户的权限范围内。

我们实现了过滤器做统一的鉴权处理,拦截所有三方请求数据的接口。为了保证鉴权的性能,我们在上一步中生成的 accessToken 会放入redis缓存中,key=accessToken,value=用户信息,用户权限信息,accessToken失效时间。鉴权的时候只需从redis读取对象判断 accessToken 是否存在以及是否有效就很简单了。
至于用户权限的校验,我们使用了 RESTFUL风格的 API,我们在 Control 层的方法中上标记权限注解 @DataPermissionScope,并在在 spring 容器启动时通过postProcessAfterInitialization接口,将每个接口的url和对应的权限对应起来放入 map 中。三方调用接口时,根据请求的url获取map中的权限范围,和redis中用户的权限范围做比对,这样就可以完成鉴权了。

数据结构 ER 图

Image_20220509110857.png
Image_20220509104952.png

接入微信硬件方案

微信对微信厂商开放了微信硬件ID的对接,与同步设备运动健康数据到微信运动的方法。对接后当用户佩戴OPPO穿戴设备并绑定到微信时,可以再微信运动好友列表露出OPPO的设备。有助于丰富设备的玩法,提高销量。
image.png

系统选型

确定了基本的实现方案,我们进一步分析可能会用到的中间件、框架,以及系统整体的组织方式。

  • 存储。我们根据当前用户存量,并以当前增量预测未来数据量,认为 MySQL 单库单表 + innodb 索引组织表已经足以承担数据存储,假如数据量超预期增长,合理运用组合索引也能实现和堆表相仿的性能。
  • 缓存。由于 accessToken 不包含权限信息,为了提高授权、鉴权速度,我们需要引入缓存中间层,选择了缓存中间件最成熟的 Redis。
  • RPC 框架。由于前期的工作中已经将三方微服务拆分出来,因此我们需要使用 RPC 框架实现实时通讯。为了保证遇到问题能及时解决,我们选择了此前项目组已经用开了的 OPPO 自研框架 esa-rpc。

    解决问题

    限流:防守三方异常流量

    在对接三方应用 keep 的时候,他们因为开发bug,在死循环中调用我们的接口,光是一个用户就在30分钟内调用了3万次。虽然我们收到了服务的告警,但在定位到问题前,服务已经经历了多次宕机、拉起了。
    我们意识到应对异常流量,如果没有限流熔断,很容易造成服务雪崩。因此,我们接入了公司框架 ESA ServiceKeeper:
    image.png
    在实际应用中,我们对并发请求数、周期内请求数、周期都作了限制。并且用压测验证了限流效果。

    微信硬件 exactly-once 投递

    和微信硬件对接的过程中,我们发现健康只有1个运动记录,但微信显示了2个运动记录的情况。经过日志排查,发现是我们重复上报了运动记录。
    我们在三方微服务使用 RocetetMQ 消费来自客户端上报的运动记录,由于 MQ 有失败重试功能,我们在调用微信硬件接口上报运动记录时遇到网络超时,抛出的异常未被捕获,导致 MQ 发起了重试。微信方面则收到了我们两次上报,导致最终显示了2条运动记录。
    RocketMQ 在默认的配置下保证 at-least-once,如果我们 try-catch 上报微信的过程,那么再次遇到网络抖动时,微信有可能收不到我们的上报,这就造成了漏报。
    网络质量是没有保证的,因此最理想的情况是微信甄别是否重复上报,于是我和微信团队接触,通过沟通,最终推动他们支持上报接口的幂等。我们的 MQ 继续保证 at-least-once,即便是重复上报了,也能保证最终的 exactly-once 效果。

    提升伺服性能

    由于部门层面有降费需求,我们希望用最少的服务器实例支撑起性能指标。通过调研,我们发现 OPPO 自研 MVC 框架 restlight 在基准测试中,相比 SpringMVC 有明显的性能提升。
    Image_20220509163842.png
    在并发数是500的情况下,CPU使用率三者都接近满负荷,大于90%,内存使用率三者都在20%左右,相差不大

  • 从TPS来看,Restlight0.1.6 是 SpringMVC2.1.3 的 300%,提升明显

  • 从RT来看,Restlight0.1.6 是 SpringMVC2.1.3 的 33%,提升明显

在后续的压力测试中,我们确认了性能提升。提升幅度请参考系统测试。

提升 Bean 转换效率

调优过程发现内存占用偏高,分析堆空间持有大量三方工具的克隆对象,通过调研引入 MapStruct组件,并在公司配的开发机器(i7 16G)上执行基准测试:
Image_20220513171703.png
通过基准测试,确认了 Bean 转换时间从 8316ms 降低至 281ms,优化幅度 96%。并在生产环境的云监控中,确认了内存下降 5% 的效果。

系统测试

压力测试结果

请参考文献《开放平台压测报告》

未来规划

应对用户量剧增

我们知道,单个服务器的 I/O 能力终是有限的,如果将来用户量剧增,我们需要有一个可扩展的方案。在系统选型中,我们使用了 MySQL、Redis 作为存储组件,在咨询专业同事后,我们得到如下的数据:

  • 单实例 MySQL 的写入瓶颈在 4000 QPS 左右,超过这个数字,MySQL 的 I/O 时延会剧量增长
  • MySQL 单表记录到达了千万级别,查询效率会大大降低,如果过亿的话,数据查询会成为一个问题
  • Redis 单分片的写入瓶颈在 2w 左右,读瓶颈在 10w 左右

解决方案:

  1. 读写分离。在查询平台系统权限、查询三方应用权限等场景下,我们可以将 MySQL 进行读写分离,让这部分查询流量走 MySQL 的读库,从而减轻 MySQL 写库的查询压力。
  2. 水平切分。在软件设计中,有一种分治的思想,对于存储瓶颈的问题,业界常用的方案就是分而治之:流量分散、存储分散,即:分库分表。
  3. Redis 分片集群。目前我们已经应用了6分片集群,可以对应 20000 * 6 = 120000 的 QPS

    参考文献

    开放平台压测报告
    云原生Web服务框架ESA Restlight