微服务最佳实践

本文记录了工作当中积累的一些关于微服务最佳实践的想法

一定要做到的点

  • 每个请求一定要有 request-id,一般由调用方生成,如果你是在推动一个遗留项目,那么可以在网关或者AOP层去生成它,并且在日志会和调用链路中携带上这个ID,这样才能够在繁杂的日志中找到特定的请求信息。建议由 uuid 或者 雪花算法 生成。
  • 请求日志和错误日志分离,这么是因为这两者具有完全不同的关注点,且前者格式相对固定。分离他们可以在清洗日志时使用不同的格式。
  • 当请求发生错误时(无论是预期内的还是预期外)都应该体现在日志当中
  • 如果你们的系统引入了调用链追踪,那么可以使用 request-id 作为 trace-id
  • 对于每一个资源的操作都应有反馈,比如删除不存在的资源或者创建同名的资源,我们都应该返回特定的错误码,通常可以使用三类: NotFoundError, ConflictError, InternalErrorValidationError,同时错误码应该有所区分——预期和非预期的(比如使用code的范围去区分是一个选择),以便于落地告警
  • 网关和微服务打印日志建议分开侧重点,网关应该打印协议层的细节,比如 http header, http code, cookie等,而微服务应该打印 应用Code, 请求参数,响应参数。这样可以在网关统计到后端微服务有多少是因为转发时的网络问题而导致访问失败,多少又是因为应用服务内的问题而失败。
  • 系统和服务都应该有明确的边界,服务边界很容易理解,就是一个独立的部署单元,系统的边界是指对所有系统外的服务的交互都应该收口,这样才能更好的进行流量治理。理想情况下我们可以通过网络策略来形成物理边界,比如k8s的namespace。但是在真实的情况中可能你要面对很多遗留服务,它们已经通过IP地址或者服务发现互相访问了,直接去改造它们成本非常大,那么你可以在逻辑上形成边界,比如提炼一个新的概念对象,将现存的服务都分门别类,然后再请求上就可以区分出流量到底来自外部还是内部。不过值得注意的是,物理边界虽然理想,但是在做异地多活时可能会面临一些挑战,比如你使用namespace来作为网络边界,那么你需要把不同地区和机房的机器都包含到一个集群中才能满足不同程度的容灾需求。
  • 谨慎对待 Error 和 Warning 级别的日志,它们可以用于反馈当前服务的健康度,可以对其进行监控和告警,同时为了控制好整个程序中的错误级别,建议不要在代码中hardcode打印日志,统一返回到上层去区分处理。
  • 乐观锁可以使用最后更新时间或者http标准下的ETag
  • 请求时一定要注意超时时间,尤其是调用链路比较长时,要保证 client 和 server 的 context 声明周期一致,通常来说,client 不应该设置自己的超时时间,统一由服务端控制。但是如果基础设施不稳定的,话,这种方式可能会导致较长的无效等待,为了达到更高的可用性,全链路超时是必须的,超时不由服务端决定,而是客户端决定。
  • 在没有使用 ThreadLocal 技术的项目中。无论在开发or基础库都应该注意日志打印时预留context参数,便于日志染色
  • 枚举的转义关系应尽量交给客户端来渲染,客户端可以从枚举的维护方自行下载缓存到本地做映射,这里有几个考虑
    • 暴露枚举值的转义表之后,各个接入方都可以自行转义,包括前端。如果你期望将转义关系隐藏在后端,只有当你的枚举值不被其他服务所关心时才能这样做,且没有带来太多的价值。
    • 每个消费房对枚举转义形式可能期望不同,比如一个数组的表现形式,树当中一个节点的表现形式等,都可能不同
    • 如果枚举的定义并不是你的服务,且业务逻辑并没有依赖这部分数据,这时仅为了满足某个消费方(通常来说是前端orApp)去依赖其他业务接口来完成转义,其实是引入了更多风险,并且没有必要,因为需要这个转义的消费方多数时候其实已经获得过转义表
  • 对于预期外的行为,服务必须要有响应,比如:
    1. 获取、更新、删除一个不存在的记录
    2. 写入冲突

项目结构(project layout)

这真是一个非常常见的问题了,我觉得这个问题需要结合场景来看待:

  • 编程语言:每个语言都有自己比较常见的项目结构,比如 golang,在各自的标准下的结构才是我们需要考量如何区分的。
  • 架构风格:由于解决的问题不同,你的软件可能会采用不同的架构风格,比如 数据流式(批处理,管道),请求/响应式(N层,面向对象),独立组件式(交互进程,事件驱动),数据中心式(数据库,黑板系统)可以参考 Architectural-Styles-Patterns。使用不同架构风格时,你的项目结构也会调整。

总的来看项目结构很难以一个万金油的模版去一统天下,都需要结合各自语言的特性以及不同架构风格来解决,这里提供一些常见选择:

  • 一般业务服务,正常都是使用 请求/响应式的架构风格,可以用 N-Tier(分层) 架构去应对,但是怎么分层也不是单一的,比如你的项目很简单,那么传统三层是足够的。但是在现今微服务遍布的时代,更推荐六边形架构+领域建模的方式来分层。
  • 如果你是一个基础服务,由于API仅仅是你的一块功能,因此不会有太层次上下游之分,比如 k8s, 网关等,一般都只有数据面+控制面,使用分层架构去应对没有带来太大的区分价值,因此都是基本都是采用 基于组件(component-based 的架构风格),特别是在 go 的项目中,你随处可见这样的案例

现今的软件系统越来越复杂,在我们解决架构风格时其实很少会去考虑自己到底使用了什么样的架构风格,因此映射到项目架构时基本也都是按照 Component or 功能子域 去划分,不会严格控制。 比如 k8s 当年的 分层讨论 中,你几乎看不到他们围绕架构风格来定义自己的项目结构,因为这样的加大参与者的心智负担,而且大型项目中,其实会囊括到多种不同的架构风格以解决不同的问题,因此体现在项目结构时,你可能很难套入一个标准的模版去解决它。因此不要给自己做太多的限制,项目结构的目的是为了开发者能够合理放置功能模块,如果你决定采用经典的分层模型,一定要先确定各个层次都是存在功能模块的,否则一个只有两层结构的项目,你根本不需要在项目结构上体现分层,否则会限制你的软件永远只有两个目录,这没有带来太大的价值。

版本控制的模式

可以参考 Git的分支管理模型

框架选择

  • 选择什么框架其实不是很重要,建议把代码构建成框架无关的形式,一般情况下可以使用一些抽象中间件完成,比如 droplet

分布式原语

可以参考 Bilgin Ibryam 的博客 Top 10 must-know Kubernetes design patterns

Mono-repo or Multi-repo

取决于软件的场景和性质,自己的经验:

  • 当仓库用于存储一个紧密耦合的产品时,如 grafana,prometheus 的前后端,k8s 的kubelet, scheduler 等,为了保证各个组件间的兼容性和产品版本的一致性,会放在同一个仓库中
  • 当仓库用于存储业务服务时,建议使用 multi-repo 因为你不知道业务的边界在哪里,什么时候会膨胀,谷歌虽然整个公司使用一个仓库,但其实他们使用了一些技术手段来保证只迁出所需部分,同时在基础设施上下足了功夫,比如提高 DevOps 的效率,权限控制,急速膨胀的commit记录等
  • 当仓库用于存储公共库,建议使用 multi-repo ,我相信谁都不会因为引用了一个库而导致把全公司的项目引用都拉进来的。

缓存

接口缓存的最佳策略:先更新后删除,虽然有部分场景会导致BadCase,但是很苛刻(一个没有缓存的查询先到达,拿到一份旧数据,准备缓存,然后一个更新请求此时到达且先处理完成,然后前者缓存数据)。除了这种策略外,如果某个接口受太多数据源影响,可以采用资源时间戳的方式来解决,将最新资源更新的时间戳存储在一个资源集的Key里面,然后比对上次缓存的结果。 https://juejin.cn/post/6844903665845665805