原因

  1. 精简rpc包,剔除mq功能
  2. 使用egg agent,支持多worker启动(支持多worker意义不大,egg 3.0的milestone对docker的singleprocess会做优化,到时候再根据情况做调整)
  3. 提供尽量方便的配置性,支持开发自定义proto文件的同时不需要格外增加代码(屏蔽掉内部实现)

设计点记录

client

  • call

连接断开: 连接断开后直接抛错,删掉记录的client,等待下次调用再发起连接,因为只有调用时才能执行建立连接,直接用调用方法重试容易出现死循环

报错: 通过判断response.body.code<0,创建error,reject error

  • stream

连接断开: 连接断开后重试连接,通过监听error事件重试

server

启动失败: 设计只支持发生在申请端口时,直接reject error

保存连接对应的client

1
2
3
const key = svcName + funcName + that._getRid();
that.callHandle.set(key,client)

触发rpcData时带上key

设置传输包大小

改动范围

fx-rpc

启动rpc服务,处理client发送的请求,调用本地方法

传输格式
1
2
3
4
5
6
7
8
9
10
11
syntax = "proto3";
package fgrid;
service Call {
rpc Call (body) returns (body) {}
}
message body {
string body = 1;
}

middle

client发送rpc请求,格式参考部分JSON-RPC

1
2
3
4
5
{
method: targetArgs.join('.'),
param: [ ...args, ctx.rpcCtx ],
}

项目回顾

重构目的

优化授权数据的存储方式,避免原先冗长的缓存计算

成果

无需再做定时的缓存刷新,据实施和客户反馈操作授权速度和流程度比原先有大幅提升

槽点和败笔

  1. 从目的出发业务基本无改动,完全搬运原先代码,也因此出现了一些纰漏
  2. 过度设计,ra-backend无意义的透传,首先侧重代码的清晰、可读性,再考虑其他规范,避免形式主义
  3. 缺少自测手段,过度依赖测试团队,效率很低
  4. 前期在基础组件花费时间过长,缺乏进度管控,后期赶工,代码质量缺少保障
  5. // 沟通问题

开发体感

业务代码

  1. 修改依赖授权数据的项目非常痛苦,要去了解原先的业务场景和数据格式后再设计接口,在后期测试时,这块花费了很大时间,出了bug难以排查
  2. 原先项目参数定义比较奇怪,使用-1表示否定,mongo支持undefined为查询条件

基础组件

  1. 缺少服务地址组件: 本地开发启动多服务联调比较麻烦,连接线上服务也需要不断修改配置文件
  2. log插件:线上报错信息不完整,没有显示具体的调用信息

工程化

解耦

  1. 服务拆分颗粒度 要与公司业务发展趋势相契合,不能生搬硬套教程,做适当整合(基础信息类)
  2. 避免类似ra-backend只做透传的项目
  3. 传入参数的校验,存在将前端传入参数直接透传查询的情况,需要加以验证和鉴权

模块化

  1. 因为docker构建缓存,私有包不自动更新,增加Jenkins配置或者写明使用包版本
  2. 私有包版本管理,不兼容问题

质量保证

  1. 最小单元测试 确保不出现语法错误(await、拼写)
  2. 健康检查,考虑开放http来提供健康检查,通过fx-rpc插件实现再调用rpc来自检

代码风格

  1. 下划线转驼峰
  2. 单文件代码行数,按controller映射service容易出现代码行数过大 需要做适当拆解

临时发布环境搭建

测试

测试准备

  1. 撤下老的项目,防止再被调用

自测能力

对复杂业务场景的自测能力

沟通

团队会议

  1. 回顾本周进度,指定下周计划,预防延期或及时做进度调整
  2. 对于有争议的问题由tl做决策避免拖延
  3. 确定责任边界,避免扯皮;回顾、调整目标,确保团队整体方向正确(避免无用功,有争议时从目标出发)
  4. 做好会议记录(记录决策)发给与会者

下阶段计划

rpc包更换

  1. 精简rpc包,剔除mq功能
  2. 使用egg agent,支持多worker启动

mq包更换

  1. 封装http版本的包
  2. 避免一个业务动作批量发送mq的场景(类似多段的分布式事务) 需要由业务侧做拆分

log

  1. 错误日志输出适配rpc,打印调用参数和traceId

减少rpc传输字段

  1. 禁用select * ,自协调参数输出

服务地址组件

  1. 暂定在本地开发使用,线上运行不依赖它
  2. 通过简单的声明即可获取服务地址,包括测试、预发、生产

技巧分享

在node中实现高效缓存
http://wmtcore.com/2020/04/23/%E9%AB%98%E6%95%88%E7%BC%93%E5%AD%98%E7%9A%84%E4%BD%BF%E7%94%A8/

反向授权性能优化
http://wmtcore.com/2020/03/20/%E5%8F%8D%E5%90%91%E6%8E%88%E6%9D%83%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/

使用场景: 对于量级小,常用的基础数据做内存缓存,提高运算速度

getRegionCache方法不仅对外输出,同时内部计算也会使用,为了保证高效,需要隐性异步,不能直接声明async

初始化缓存

返回Promise 同步等待

1
2
3
4
5
6
7
8
9
function getRegionCache(key) {
if (!cache) {
return new Promise(resolve => {
reflashCache().then(() => {
resolve(cache.get(key))
});
});
}
}

调用者拿到的是Promise对象,直接await即可

1
2
3
async getRegionCacheAPI(key) {
return await getRegionCache(key)
}

缓存异步刷新

  • 对于这类基础数据的缓存,低实时性,通过自维护方式失效,类似LRU的实现

缓存到失效时间后通过process.nextTick放入微任务队列异步刷新, 同步计算先拿旧数据继续执行

1
2
3
4
5
6
7
function getRegionCache(key) {
if (overTime) {
process.nextTick(() => reflashCache());
updateCacheTime = Date.now();
}
return cache.get(key);
}

缓存击穿

  • 在缓存初始化和失效时,大量并发请求同时触发缓存刷新,严重影响数据库性能

通过设置once事件监听缓存刷新完成,待缓存刷新结束后,依次返回结果,避免并发刷新缓存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const event = new events.EventEmitter();
event.setMaxListeners(100); //注意设置监听者数量,超过数量会触发leak memory告警
await reflashCache() {
await new Promise(async resolve => {
event.once('finish', resolve);
if (eventStatus === 'ready') {
eventStatus = 'pending';
}
event.emit('finish');
eventStatus = 'ready';
}
});
}

总结

通过将数据库查询任务放入异步队列,避免了运算时同步等待,体现出node的性能优势

利用事件监听能力,很方便就能处理缓存击穿的问题

不变只是愿望,变化才是永恒

试验性工厂和增大规模

开发使用的环境由于规模、量级的限制,导致第一版开发的软件在真实环境运行时易出现各种问题,需要在测试中不断增大规模、模拟真实环境

把第一版开发的产品发布给顾客,可以获得时间,但是对于用户,使用极度痛苦;对于重新开发的人员,分散了精力;对于产品,影响了声誉,即使最好的再设计也难以挽回名声

唯一不变的就是变化本身

新的系统概念或新技术会不断出现,所以老版的系统必须被抛弃,但即使是最优秀的项目经理,也不能在最开始解决这些问题

  • 开发人员交付的是用户满意程度,而不仅仅是实际的产品
  • 用户的实际需要和用户感觉会随着程序使用而变化

目标上的变化不可避免,而且设计策略和技术上的变化也不可避免,随着学习的过程更改设计

前进两步,后退一步

对于一个广泛使用的程序,其维护总成本通常是开发成本的 40%或更多,该成本受用户数目的严重影响。用户越多,所发现的错误也越多

Resize icon

  • 缺陷修复总会以(20-50)%的机率引入新的bug

原因:

  1. 看似很轻微的错误,实际却是系统级别的问题,如果没有非常详细的文档,是很难被发现
  2. 维护人员常常不是编写代码的开发人员,而是一些初级程序员或者新手

成本: 在每次修复之后,必须重新运行先前所有的测试用例,从而确保系统不会以更隐蔽的方式被破坏

设计实现的人员越少、接口越少,产生的错误也就越少 微服务

前进一步,后退一步

  • 维护越多系统越混乱,所有修改都倾向于破坏系统的架构,增加了系统的混乱程度
  • 用在修复原有设计上瑕疵的工作量越来越少,而早期维护造成的漏洞所引起修复工作越来越多

系统软件开发是减少混乱度(减少熵)的过程,所以它本身是处于亚稳态的。软件维护是提高混乱度(增加熵)的过程,即使是最熟练的软件维护工作,也只是放缓了系统退化到非稳态的进程

机器在变化,配置在变化,用户的需求在变化,所以现实系统不可能永远可用。基于原有系统的重新设计是完全必要的

认识到微服务开发的复杂性

  1. 本地开发要有一个好的开发机器,糟糕的开发机器将会导致糟糕的开发实践
  2. 确保所有服务都使用构建工具,能在一台新机器上构建整个应用程序,而不需要进行太多配置,让开发人员能轻松地在本地运行应用程序的各个部分
  3. 使用Kubernetes,利用Telepresence以便轻松调试 Kubernetes 集群中的应用程序

如果对微服务开发的复杂性缺乏理解,那么团队速度将会随着时间的推移而下降

及时将库和工具更新到最新版

所有服务的依赖项版本保持同步,为依赖升级创建技术债务项,并应作为会议的一部分加以讨论,并定期予以解决,将所有依赖项更新到最新版本

不止包括升级依赖包的版本,还包括架构修正,如当引入一个新工具时,可能可以替换掉原先多个工具,有助于降低复杂性

几年前,许多团队开始将 Spring Cloud Netflix OSS 项目用于微服务。他们使用像 Kubernetes 这样的容器编排工具,但是因为是从 Netflix OSS 开始的,所以他们没有使用 Kubernetes 提供的所有功能。当 Kubernetes 内置了服务发现时,他们仍然使用 Eureka 作为服务发现

避免使用共享服务来做本地开发

举例共享数据库

  1. 一个开发人员可以删除其他开发人员为他们工作编写的数据
  2. 开发人员害怕实验,因为他们的工作会影响其他团队成员
  3. 很难单独测试更改。你的集成测试将变得不可靠,从而进一步降低开发速度
  4. 容易出现不一致和不可预测的状态,开发人员希望在表是空的时候测试边缘情况,但其他开发人员需要一个表来记录
  5. 只有共享数据库拥有系统工作所需的所有数据,团队成员失去了更改的可追溯性
  6. 如果未连接到网络,就很难开展工作

它也可以是消息队列、集中缓存(如 Redis)或任何其他可以发生改变的服务

代码托管平台的可见性

在托管平台按产品、服务对微服务进行分组,方便了解代码库结构

服务有明确定义

避免拆分过多服务,会导致管理成本直线上升,

应用单一责任原则(Single Responsibility Principle)来了解微服务是否变得过大,做的事情是否过多

任何服务都不应该直接与其他服务的数据库通信

服务通信是微服务系统性能低下的首要原因: 如果两条信息相互依赖,那么它们应该属于同一个服务,服务的自然边界应该是其数据的自然边界

明确代码重用策略

对处理相同问题的代码,使用包封装,发布在包管理平台,并通过构建工具来让依赖的微服务及时更新

没有适合的工具和自动化的情况下,使用微服务会导致灾难

多语言编程设计

如果你的开发人员还不够成熟的话,那么无论你使用什么编程语言,你开发的都将是糟糕的产品

一个组织可以指定2、3个语言列表,并列出语言的优势

在选择一门语言前,应该考虑以下一些问题:

  1. 找到成熟的开发人员有多容易
  2. 重新培训开发人员掌握新技术有多容易?我们发现 Java 开发人员可以相对容易地学习 Golang。
  3. 代码的可读、可维护性
  4. 就工具和库的方面而言,生态系统有多成熟?

不仅仅局限于编程语言,也适用于数据库领域, 要始终考虑使用多种技术的维护和操作方面

人员的依赖性

大多数团队专注于他们的特定服务,因此他们并不了解完整的生态系统

确保所有团队都有一个架构团队的代表,使每个团队与整个架构的路线图和目标保持一致(人月神话中有提过,外科手术团队,只需要协调各团队中的架构师)

维护文档

需要维护的文档:

  1. 设计文档
  2. C4 模型中的上下文和容器图
  3. 以架构决策记录的形式跟踪关键架构决策
  4. 开发人员入门指南

在Gitlab中维护所有的文档

功能不要超过平台成熟度

微服务要比传统的单体式应用更为复杂

需要考虑分布式跟踪、可观察性、混沌测试、函数调用与网络调用、服务间通信的安全服务、可调试性等等。这需要在构建正确的平台和工具团队方面付出认真的努力和投资

自动化测试

微服务架构为测试地点和测试方式提供了更多选择

微服务测试的金字塔

Resize icon

交流的缺乏导致了争辩、沮丧和群体猜忌。很快,部落开始分裂——大家选择了孤立,而不是互相争吵

缺少相互沟通会导致进度灾难、功能的不合理和系统缺陷

对程序功能的修改要从系统角度来考虑和衡量,及时做公开,告知

项目工作手册

是项目文档的集合,包括目的、外部规格说明、接口说明、 技术标准、内部说明和管理备忘录,帮助后来者了解产品设计

事先将项目工作手册设计好,能保证文档的结构本身是规范的

控制信息发布:确保信息能到达所有需要它的人的手中,对所有的备忘录编号或者树状的索引结构

记录修订日期记录和标记变更标识条

编程人员仅了 解自己负责的部分,而不是整个系统的开发细节时,工作效率最高,先决条件是 精确和完整地定义所有接口

大型编程项目的组织架构

团队组织的目的是减少不必要交流和合作的数量

减少交流的方法是人力划分和限定职责范围

树状组织架构:

  1. 任务(a mission)
  2. 产品负责人(a producer)
  3. 技术主管和结构师(a technical director or architect)
  4. 进度(a schedule)
  5. 人力的划分(a division of labor)
  6. 各部分之间的接口定义(interface definitions among the parts)

产品负责人:

  1. 主要的工作是与团队外部,向上和水平地沟通,建立团队内部 的沟通和报告方式
  2. 组建团队,划分工作及制订进度表,要求资源,确保进度目标的实现,根据环境的变化调整资源和团队的构架

技术主管:

  1. 提供整个设计的一致性和概念完整性,控制系统的复杂程度
  2. 对设计进行构思,确定内部结构。
  3. 提供技术问题的解决方案,或者根据需要调整系统设计。
  4. 他的沟通交流在团队中是首要的。他的工作几乎完全是技术性的

产品负责人和技术主管的组合方式

产品负责人和技术主管是同一个人:

  1. 只适用于几个人的小团队开发
  2. 同时具有管理技能和技术技能的人很难找到,大型项目中,每个角色都必须全职工作无法保证还能抽空做别的

产品负责人作为总指挥,技术主管充当其左右手:

  1. 实现难度在建立技术决策上的权威
  2. 必须对技术主管的技术才能表现出尊重,支持他的技术决定
  3. 在主要的技术问题出现之前,私下讨论它们,达到在基本的技术理论上具有相似观点
  4. 一些技巧 通过一些微妙状态特征暗示来(如,办公室的大小、地毯、装修、复印机等等)体现技术主管的威信
  5. 这种组合可以使工作很有效 项目经理可以使用并不很擅长管理的技术天才来完成工作

技术主管作为总指挥,产品负责人充当其左右手:

  1. 小型的团队是最好的选择
  2. 参考 外科手术队伍

对于真正大型项目中的一些开发队伍,产品负责人作为管理者是更合适的安排

之前的问题

在做反向计算时,主要涉及到区域数据的比较,通过查出所有子集再循环剔除,

数据量、大运算慢,频繁根据region_start_id查数据库,返回结果因为展开到商品,对反向涉及到的商品都会带上庞大的区域数据

插曲

深拷贝优化: 展开的授权数据,要从原始授权继承区域,需要深拷贝,直接深拷贝Object太慢,通过immutable做了优化,但涉及到的商品区域数据最后返回时要toJS,速度较慢

region缓存: region_start_id查数据库,将查询id数组长为1的做了lru,目的是缓存区域层级高、重复多的数据,在原始授权数据很多时有较大的速度提升

改造

根本问题

计算区域时,无法通过regionID直接知道层级,并做层级运算(会有子父级数据的剔除和补充)

实现方案

受树形数据加编号启发,通过一个层级key来表示regionID的层级信息,直接对key做运算

将原先展开循环的层级运算,变成字符串比较

缓存:

  • cacheRegionKeyObj key对应region记录
  • cacheRegionIdObj regionId对应key
  • cacheRegionKeysRank key对应子集keys,同层级数据groupby加列转行

在做层级运算时,通过字符串运算即可得到上下级key再到缓存里换出子集keys,再对展开结果做排除即可完成层级运算

优势:

  1. 深拷贝只有层级key数组
  2. 层级运算不涉及数据库
  3. 返回结果只有展开的层级key,前端通过缓存cacheRegionKeyObj换算即可

总结

  1. 深拷贝处理可以尝试immutable
  2. 对于树形数据的层级计算,考虑转成平铺的形式,同时保留层级信息,需要将数据缓存在本地计算,适合数据量较小的场景

文档化的规格说明——手册

  • 产品的外部规格说明,它描述和规定了用户所见的每一个细节,也是结构师主要的工作产物

随着系统的使用和反馈,规格说明中难以使用的地方也不断地被修改,对实现人员而言,修改需要阶段化,有进度时间表和版本

实现人员的设计和创造不应该被手册限制,手册要避免描述内部实现

体系结构设计人员必须为自己描述的特性准备一种实现方法,但是他不应该试图支配具体的实现过程。

规格说明的风格

  • 清晰、完整和准确,每条说明都必须重复所有的基本要素,所有文字都要相互一致
  • 由一两个人将结论转换成书面规格说明,保持文字和产品之间的一致性

形式化定义

  • 使用形式化标记方法达到所定义需要的精确程度

优缺点 精确完整,差异得更加明显,可以更快地完成。缺点是不易理解 需要记叙性文字的辅助,才能使内容易于领会和讲授

决不要携带两个时钟出海,带一个或三个 如果同时使用形式化和记叙性定义,则必须以一种作为标准,另一种作为 辅助描述,并照此明确地进行划分

形式化定义仅仅用于外部功能说明它们是什么 有时会通过一段实现该功能的程序来定义,但只是说明功能,不能限定体系结构

设计实现可以作为一种形式化定义的方法

优点所有问题可以通过试验清晰地得到答案,从来不需要争辩和商讨,回答是快捷迅速的
缺点 可能过度地规定了外部功能。有时会给出未在计划中的意外答案;特别容易引起混淆,当实现充当标准时,还必须防止对实现的任何修改

周会和大会

周会

每周半天的会议,所有的结构师,加上硬件和软件实现人员代表和市场计划人员参与,由首席系统结构师主持

在会议之前分发建议、会议内容,解决方案会被传递给结构师并做记录,当决策没有达成共识时由首席结构师来决定

周会优势: 相同小组每周交流,对相关内容比较了解,不需要安排额外时间培训;深刻理解所面对的问题,并且与产品密切相关;正式的书面建议集中了注意力,强制了决策的制订,避免了会议草稿纪要方式的不一致;清晰地授予首席结构师决策的权力,避免了妥协和拖延

大会

随着时间的推移,一些决定、一些小事情并没有被某个参与者真正地 接受。对于这些问题,有时周例会没有重新考虑,慢慢地, 很多小要求、公开问题或者不愉快会堆积起来。通过年度大会解决这些堆积起来的问题

大多数条目的规模很小,每个不同的声音都有机会得到表达。然后会制订出决策,每个人都在倾听、参与,每个人对复杂约束和决策之间的相互关系有了更透彻的理解,使决策更容易被接受

原因

  1. 存在一些耗时较长的运算,需要从web服务中移出,比如缓存计算、报表导出
  2. 管控力度:原先的运算集群采用mq通信方式,master对worker的管控力度不够,会出现一个任务重复失败拖垮集群的现象
  3. worker之前不能区分,原因还是没有直接由master派发任务,也无法做跨语言调度

目标

master

  1. 稳定、高可用,不执行具体运算
  2. 接收、记录待执行的任务,按任务优先级派发,记录任务执行结果,支持超时、重试、失败状态
  3. 能管控worker生命,知道它的运行状态和任务进度,根据worker状态派发任务
  4. 向业务方通知运行结果

worker

  1. 启动时向master注册自己的环境和算力
  2. 定时汇报自己的运行状态
  3. 支持多语言

实现

通信

基于grpc的stream方式通信,worker端目前采用重启方式重连,master端可通过end事件清除失效worker

1
2
3
4
5
6
7
service Greeter {
rpc Command (stream body) returns (stream body) {}
}
message body {
string body = 1;
}

master和worker的结构

  • 基于egg,风格结构与后端业务框架基本相同
  • 主要分为job和life_cycle,分管任务和生命周期

Resize icon

master
  • 通过mq记录任务,并定期读取任务到缓存,按优先级排序,
  • 处理worker连接,定期检查连接情况
  • 派发任务,只在新worker连接和任务完成时执行,定期检查任务执行状态
worker
  • 接受master派发的任务调用本地service/method执行,在表中记录执行结果
  • 定期自检健康发送心跳给master

使用

任务代码的编写

  • 在worker端实现
  • 代码位置在service文件夹
1
2
3
4
5
6
// app/service/authorization.js
const Service = require('@fgrid/egg').Service;
class AuthorizationService extends Service {
async deleteAuthorization(){}
}

代码结构与业务框架一致,业务数据通过rpc获取,原则上不直连业务数据库

业务服务调用任务

  • sdk直接写在fgrid-middleware

调用方式和rpc调用类似

1
2
// 通过mifStart来调用分布式任务,后面接serviceName.methodName
this.ctx.mifStart.authorization.deleteAuthorization()

计划实现功能

  1. master高可用
  2. worker多语言版和算力等属性
  3. 定时任务

结构师的交互准则和机制

成本

  • 结构师能在设计早期从开发那得到成本
  • 开发可以增高或降低估的计成本,来反映对设计的好恶

尽早交流和持续的与开发沟通能使结构师有较好的成本意识,以及使开发人员获得对设计的信心,并且不会混淆各自的责任分工

成本过高时的处理
  • 削减设计
  • 跟开发建议成本更低的实现方法(挑战估算的结果)

第二个是结构师固有的主观感性反应,是在向开发人员的做事方式提出挑战

  1. 开发承担创造性和发明性的实现责任,结构师只能建议,而不能支配
  2. 为所指定的说明提供实现方法,并对改方法保持低调和平静,并接受其他任何能达到目标的方法
  3. 准备放弃坚持所作的改进建议

一般开发人员会反对体系结构上的修改建议,通常他是对的——当正在实现产品时, 某些特性的修改会造成意料不到的成本开销

自律——开发第二个系统所带来的后果

  • 第二个系统是设计师们所设计的最危险的系统

开发第一个系统时,结构师倾向于精炼和简洁。知道自己对正在进行的任务不够了解,所以会谨慎仔细地工作,修饰功能和想法被小心谨慎地推迟,导致过分地设计第二个系统

着手第三、第四个系统时,通过之前的经验得到此类系统通用特性的判断,而且系统之间的差异会帮助他识别出经验中不够通用的部分

开发第二个系统的后果(second-system effect)与纯粹的功能修饰和增强明显不同,由于技术、设计的变化,可能会对已经落后技术进行细化、精炼

  1. 关注系统的特殊危险,避免功能上的修饰;根据系统基本理念及目的,舍弃一些功能
  2. 每个小功能分配一个值: 每次改进,有一个数据指标,比如功能 x 不超 过 m 字节的内存和 n 微秒。能作为决策的向导,在物理实现期间充当指南和对所有人的警示.例如facebook的Messenger,为每个功能设置了代码大小预算,要求工程师负责遵守预算约束,作为功能接受标准的一部分(构建一个系统来计算每个功能的二进制大小权重)

项目经理如何避免画蛇添足(second-system effect)拥有两个系统以上经验的结构师的决定。同时,保持对特殊诱惑的警觉,不断提出正确的问题,确保原则上的概念和目标在详细设计中被完整实现