『数据库』微服务化的基石——持续集成


一、持续集成对于微服务的意义:拆之前要先解决合的问题在很多微服务化的文章中 , 很少会把持续集成放在第一篇 , 因为大多数的文章都会将如何拆的问题 , 例如拆的粒度 , 拆的时机 , 拆的方式 。
为什么需要拆呢?因为这是人类处理问题的本质方式:将一个大的复杂问题 , 变成很多个小问题解决 。
所以当一个系统复杂到一定程度 , 当维护一个系统的人数多到一定程度 , 解决问题的难度和沟通成本大大提高 , 因而需要拆成很多个工程 , 拆成很多个团队 , 分而治之 。
然而当每个子团队将子问题解决了 , 整个系统的问题就解决了么?你可以想象你将一辆整车拆成零件 , 然后再组装起来的过程 , 你就可以想象拆虽然不容易 , 合则更难 , 需要各种标准 , 各种流水线 , 才能将零件组装称为车 。
我们先来回顾一下拆的过程 。
最初的应用大多数是一个单体应用:

『数据库』微服务化的基石——持续集成
本文插图
一个Java后端 , 后面跟一个数据库 , 基本上就搞定了 。
随着系统复杂度的增加 , 首先Java程序需要做的是纵向的拆分 。

『数据库』微服务化的基石——持续集成
本文插图

  1. 首先最外面是一个负载均衡 , 接着是接入的Nginx , 做不同服务的路由 。
  2. 不同的服务拆成独立的进程 , 独立部署 , 每个服务使用自己的数据库和缓存 , 解决数据库和缓存的单点瓶颈 。
  3. 数据库使用一主多从的模式 , 进行读写分离 , 主要针对读多写少的场景 。
  4. 为了承载更多的请求 , 设置缓存层 , 将数据缓存到Memcached或者Redis中 , 增加命中率 。
  5. 当然还有些跨服务的查询 , 或者非结构化数据的查询 , 引入搜索引擎 , 比关系型数据库的查询速度快很多 。


『数据库』微服务化的基石——持续集成
本文插图
在高并发情况下 , 仅仅纵向拆分还不够 , 因而需要做真正的服务化 。
一个服务化的架构如图所示 。
  1. 首先是接入层 , 这一层主要实现API网关和动态资源和静态资源的分离及缓存 , 并且可以在这一层做整个系统的限流 。
  2. 接下来是Web层 , 也就是controller , 提供最外层的API , 是对外提供服务的一层 。
  3. 下面是组合服务层 , 有时候被称为编排层 , compose层 , 是实现复杂逻辑的一层 。
  4. 下面是基础服务层 , 是提供原子性的基本的逻辑的一层 , 他下面是缓存 , 数据库 。
  5. 服务之间需要治理 , 需要相互发现 , 所以一般会有dubbo或者springcloud一样的框架 。
  6. 对所有的服务 , 都应该有监控告警 , 及时发现异常 , 并自动修复或者告警运维手动修复 。
  7. 对于所有的服务的日志 , 应该有相同的格式 , 收集到一起 , 称为日志中心 , 方便发现错误的时候 , 在统一的一个地方可以debug 。
  8. 对于所有的服务的配置 , 有统一的管理的地方 , 称为配置中心 , 可以通过修改配置中心 , 下发配置 , 对于整个集群进行配置的修改 , 例如打开熔断或者降级开关等 。
通过简单的描述 , 大家可以发现 , 从一个简单的单体应用 , 变成如此复杂的微服务架构 , 除了关心怎么拆的问题 , 还必须关注:
  • 如何控制拆的风险
  • 如何保证代码质量
  • 如何保证功能不变 , 不引入新的Bug
答案当然就是集成 , 从一开始就集成 , 并且不断的集成 , 反复的将拆分的模块重新组合 , 看看是否能够顺利组合起来 , 并且保证功能的不变 。分页标题
要是不没事儿就组合一下 , 天知道几个月以后还能不能合的起来 。
别忘了程序是人写的 , 你和你媳妇长时间不沟通都对不上默契 , 别说两个程序员了 。
二、持续集成就是不断的尝试在一起集成就是在一起 。

『数据库』微服务化的基石——持续集成
本文插图
集成的逻辑
为什么需要一个统一的代码仓库Git来做代码管理呢?是为了代码集成在一起 。
为什么需要进行构建build呢?就是代码逻辑需要集成在一起 , 编译不出错 。
为什么要单元测试呢?一个模块的功能集成在一起能够正确工作 。
为什么需要联调测试Staging环境呢?需要将不同模块之间集成在一起 , 在一个类生产的环境中进行测试 。 最终才是部署到生产环境中 , 将所有人分开做的工作才算真正的合在了一起 。

『数据库』微服务化的基石——持续集成
本文插图
持续集成解决的问题
持续集成就是制定一系列流程 , 或者一个系列规则 , 将需要在一起的各个层次规范起来 , 方便大家在一起 , 强迫大家在一起 。
三、持续集成 , 持续交付 , 持续部署 , 敏捷开发 , DevOps都啥关系?这些概念都容易混淆 , 他们之间是什么关系呢?

『数据库』微服务化的基石——持续集成
本文插图
持续集成 , 持续交付 , 持续部署 , 敏捷开发 , DevOps的关系
敏捷开发Agile是一种开发流程 , 是一种快速迭代的开发流程 , 每个开发流程非常短 , 长到一个月 , 短到两个星期 , 就会是一个周期 , 在这个周期中 , 每天都要开会同步 , 每天都要集成 。 正是因为周期短 , 才需要持续的做这件事情 , 如果一个开发周期长达几个月 , 则不需要持续的集成 , 最后留几个星期的集成时间一起做也是可以的 , 但是这样就不能达到互联网公司的快速迭代 , 也是我们常常看到传统公司的做法 。
持续集成往往指对代码的提交 , 构建 , 测试的过程 , 也就是上述的在一起的过程 。
持续交付是指将集成好的交付物 , 例如war , jar , 或者容器镜像 , 部署在联调环境 , 或者预发环境的过程 。
持续部署是指将交付物持续部署在生产环境的过程 。
我们常说CICD , CD有时候指的是Delivery交付 , 有的是指Deployment部署 , 对于非生产环境 , 自动部署是没有问题的 , 对于生产环境 , 往往还是需要有专人来进行更为严肃的部署过程 , 不会完全的自动化 。
接下来就是DevOps , DevOps不只是CICD , 除了技术和流程 , 还包含文化 。 例如容器化带来的一个巨大的转变是 , 原来只有运维关心环境的部署 , 无论是测试环境 , 还是生产环境 , 都是运维搞定的 , 而容器化之后 , 需要开发自己写Dockerfile , 自己关心环境的部署 。 因为微服务之后 , 模块太多了 , 让少数的运维能够很好的管理所有的服务 , 压力大 , 易出错 , 然而开发往往分成很多的团队 , 每个模块自己关心自己的部署 , 则不易出错 , 这就需要运维一部分的工作让研发来做 , 需要研发和运维的打通 , 如果公司没有这个文化 , 研发的老大说我们不写Dockerfile , 则DevOps是搞不定的 。
四、从一个持续集成的日常 , 看上述的几个概念如何实践

『数据库』微服务化的基石——持续集成
本文插图
持续集成的流程
这是一个持续集成的流程 , 但是运行起来更加的复杂 。
首先 , 项目开发的流程使用的是Agile , 用常见的Scrum为例子 。

『数据库』微服务化的基石——持续集成分页标题
本文插图
Scrum
每天早上第一件事情 , 就是开站会standup meeting , 为什么要站着呢?因为时间不能太长 , 微服务的一个模块 , 大概需要5-9人的团队规模 , 如果团队规模太大了 , 说明服务应该进行拆分了 , 这个团队规模 , 是能够保证比较短的时间之内过完昨天的状态的 。
一定要大家一起开 , 而不要线下去更新Jira , 虽然看起来一样 , 但是执行起来完全不一样 。 只有大家一起开 , 一起看燃尽图 , 一起说我昨天做了什么 , 今天打算做什么 , 有什么阻碍 , 才能够让大家都了解情况 , 不要期望大家会去看别人的Jira , 经验告诉你 , 不会的 。
而且这个站会对于开发是比较大的压力 , 例如你的一个功能block了依赖方的开发 , 在会议上会暴露出来 , 大家都知道这件事情了 , 一天block , 两天block , 第三天你都不好意思去说了 , 这会强迫你将大任务 , 比如原来写1周干一件什么事情 , 写成小时级别 , 这样每天你都有的说 , 昨天完成了一个task , 而不是周只在那里说干同样一件事情 , 而且一旦有了block , team lead会知道这件事情 , 会帮你赶紧解决这个事情 , 推进整个项目的进展 。 让一个技术人员在团队面前承认这件事情我尝试了几天 , 的确搞不定了 , 也是一种压力 。
站会中的内容其实在前一天晚上就要开始准备了 。
持续集成要求每天都提交代码 , 这样才能降低代码集成的风险 , 不能埋头写一周一起提交 , 这样往往集成不成功 。 怎么样才能鼓励每天都提交代码呢?一个就是第二天的站会 , 你这个功能代码提交了 , 单元测试通过了 , 第二天才能说做完了 , 否则不算 , 这就逼得你 , 将大任务拆成小任务 , 每天都多次提交 。
而且Git的提交方式 , 是后提交者有责任去merge , 保证代码的编译通过和测试通过 , 你会发现 , 如果你不及时提交 , 等你改了一大片代码 , 别人都提交完了 , 这一大片的冲突都是你来merge , 测试用例不通过的你来fix , 所以逼的你有一个小的功能的改动 , 就尽早提交 , pull一下发现没有人提交 , 赶紧提交 。
提交不是马上进入主库 , 而是需要代码审核 , 这是把控代码质量的重要的环节 。
代码质量的控制往往每个公司都有文档 , 甚至你可以从网上下载一篇很长很长的Java代码规范 。 但是我们常常看到的例子是 , 规范是有 , 但是虱子多了不咬人 , 规范太多的 , 谁也记不住 , 等于没有规范 。
所以建议将复杂的规范通过项目组内部的讨论 , 简化为简单的10几条军规 , 深入人心 , 大家都容易记住 , 并且容器执行 。
代码审核往往需要注意下面的几方面:
  • 代码结构:整个项目组应该规定统一的代码组织结构 , 使得每个开发拿到另一个人的代码 , 都能看的熟悉的面孔 。 这也是scrum中提倡的每个开发之间是可替代的 , 当一个模块有了阻碍 , 其他人是可以帮上忙的 。 至于核心的逻辑 , 估计审核人员也来不及细看 , 这不要紧 , 核心逻辑是否通过 , 不能靠眼睛 , 要靠测试 。
  • 有没有注释 , 尤其是对外的接口 , 应该有完善的注释 , 方便自动生成接口文档 。
  • 异常的处理 , 是否抛出太过宽泛的异常 , 是否吞掉异常 , 是否吞掉异常的日志等 。
  • 对于pom是否有修改 , 引入了新的jar 。
  • 对于配置文件是否有修改 , 对外访问是否设置超时
  • 对于数据库是否有修改 , 是否经过DBA审核
  • 接口实现是否幂等 , 因为Dubbo和springcloud都会重试接口 。 接口是否会升级 , 是否带版本号
  • 是否有单元测试
当然还有一些不容易一眼看出来的 , 可以通过一段时间通过统一的代码review , 来修改这些问题:
  • 某个类代码长度过长
  • 设计是否合理 , 高内聚低耦合分页标题
  • 数据库设计是否合理
  • 数据库事务是否使用合理
  • 代码是否有明显的阻塞
代码审核完毕之后提交上去之后 , 一个是要通过静态代码审查 , 可以发现一些可能带来代码风险的问题 , 例如异常过于宽泛等 。
在就是要通过单元测试 。 我们应该要求每个类都要有单元测试 , 并且单元测试覆盖率要达到一定的指标 。 单元测试要有带Mock的模块内的集成测试 。
在编译过程中会触发单元测试 , 单元测试不通过 , 已经代码覆盖率 , 都会统计后发邮件 , 抄送所有的人 , 这对于研发来讲又是一个压力 。
当有一天你的提交break掉了测试 , 或者代码覆盖率很低 , 则就像通报批评一样 , 你需要赶紧去修改 。
单元测试完毕之后 , 就会上传成果物 , 或者是war或者是jar , 一般会用nexus , 因为有版本号 , 有md5 , 可以保证安装在环境中的就是某个版本的某个包 , 我们还遇到过有使用FTP的 , 这样一个是很难保证版本号的维护 , 升级和回滚比较难弄 , 另一个是没有md5 , 很可能包不完整都有可能的 , 而且一旦发生 , 很难发现 。
如果使用了容器 , 则还需要编译Dockerfile , 使用Docker镜像作为交付 , 能够实现更好的环境一致性 , 保证原子的升级和回滚 。
每天下班前 , 当天的代码需要提交到库中去 , 晚上会做一次统一的环境部署和集成测试 。
每天晚上凌晨 , 会有自动化的脚本将Docker镜像通过编排部署一个完整的环境 , 然后跑集成测试用例 , 集成测试用例应该是基于API的 , 很多的公司是基于UI的 , 这样由于UI变化太快 , 还有UI不能覆盖所有的场景 , 所以还是建议UI和API分离 , 通过API进行集成测试 , 有了每天的测试 , 才能保证每天晚上的版本都是可以交付的版本 , 也保证我们微服务拆分的时候 , 尽管改了很多 , 不会因为新的修改 , 破坏掉原来能够通过的测试用例 , 保证不会有了新的 , 坏了旧的 。
这个集成测试或者叫回归测试每天晚上都做 , 都是在一个全新的环境中 , 这就是持续部署和持续交付 。
如果某一天测试不通过 , 则会发出邮件来 , 是因为当天谁的哪个提交 , 导致测试不通过 , 抄送所有人 , 这是另一个压力 。
所以第二天的站会上 , 昨天你完成了哪些功能 , 是否提交了 , 是否完成了单元测试 , 是否通过了集成测试 , 就都知道了 , 你需要给大家一个解释 , 然后进入到新一天的开发 。
到了两周 , 一个周期完毕 , 可以上线到生产环境了 , 可以通知有权限的运维进行操作 , 但是也是通过自动化的脚本进行部署的 。
这就是整个过程 , 层层保证质量 , 从中可以看到 , 敏捷开发 , 持续集成 , 持续交付 , 持续部署 , DevOps是互相联系的 , 少了任何一个 , 整个流程都玩不转 。
五、有关代码结构代码结构往往包括:
  • API接口包
  • 访问外部服务包
  • 数据库DTO
  • 访问数据库包
  • 服务与商务逻辑
  • 外部服务
如果使用Dubbo RPC , 则API接口往往在一个单独的jar里面 , 被服务端和客户端共同依赖 , 但是使用了springcloud的restful方式就不用了 , 只要在各自的代码里面定义就可以了 , 会变成json的方式传递 , 这样的好处是当jar有多个版本依赖 , 需要升级的时候 , 关系非常复杂 , 难以维护 , 而json的方式比较好的解决了这个问题 。
这个模块提供了哪些接口 , 只要到API接口这个package下面找就可以了 。 因为无论是Dubbo还是springcloud , 接口的调用都会重试 , 因而接口需要实现幂等 。
访问外部服务的包 , 这将所有对外的访问独立出来 , 好处一是可以抽象出来 , 在服务拆分的时候 , 可能会用到 , 例如原来支付的逻辑在下单的模块中 , 要讲支付独立出来 , 则会有一个抽象层 , 涉及到老的支付方式 , 还是调用本模块中的逻辑 , 涉及到新接入的支付方式使用远程调用 , 有了这一层方便的多 。 好处二是可以实现熔断 , 当被调用的服务不正常的时候 , 在这里可以返回托底数据 。 好处三是可以实现Mock , 这样对于单元测试来讲非常好 , 不用依赖于其他服务 , 就可以自己进行测试 。分页标题
DTO和访问数据库的包 , 看到了这些数据结构 , 会帮助程序员快速掌握代码逻辑 , 不知道大家有没有这个体验 , 你去看一个开源软件的代码 , 首先要看的是他的数据结构 , 数据结构和关系看懂了 , 代码逻辑就比较容易懂了 , 如果数据结构没看懂 , 则光看逻辑 , 就容易云里雾里的 。
还有就是核心的代码逻辑和对接口的实现 。 在这里面是软件代码设计的内功所在 , 但是却不是流程能够控制的 。
六、有关接口设计规范上面也说过了 , Dubbo和Springcloud会对接口进行重试 , 因而接口需要保持幂等 。 也即多次调用 , 应该产生一致的结果 , 例如转账1元 , 因为调用失败或者超时重试的时候 , 最终结果还应该是转账1元 , 而非调用两次变成转账2元 。
  • 幂等判断尽量提前 , 可以使用ID作为判断条件 。
  • 接口的实现应该尽量避免阻塞 , 可以使用异步方式提升性能 。
  • 接口应该包括能够区分不同情况的异常 , 而非抛出宽泛的Exception , 不能吞掉异常 。
  • 接口的实现要有足够的容错性 , 以及对不同版本的兼容性 。 当要引入新接口的时候 , 使用先添加 , 后删除的方式 。
  • 接口应该有良好的注释 。
七、有关代码设计对于代码的设计 , 这里常说的就是SOLID原则 。
  • S是单一责任原则 , 如果你的代码中有一个类行数太长 , 可能你需要重新审视一下 , 是不是这个类承担了过多的责任 。
  • O是开放关闭原则 , 比较拗口 , 对扩展开放 , 对修改关闭 。 思想是对于代码的直接修改是非常危险的事情 , 因为你不知道这段代码原来被谁用了 , 而且当时候用的时候 , 面临的情况都是怎样的 。 因而不要贸然修改一段代码 , 而是选择用接口进行调用 , 用实现进行扩展的方式进行 。 当你要实现一段新的功能的时候 , 不要改原来的代码 , 也不要if-else , 而是应该扩展一种实现 , 让原来的调用的代码逻辑还是原来的 , 在新的情况下使用新实现的代码逻辑 。
  • L是里氏替换原则 , 如果基于接口进行编程 , 则子类一定要能够扩展父类的功能 , 如果不能 , 说明不应该继承与这个接口 。 例如你的实现的时候 , 发现接口中有一个方法在你这里实在对应不到实现 , 不是接口设计的问题 , 就是你不应该继承这个接口 , 绝不能出现not implemented类似之类的实现方法 。
  • I是接口隔离原则 , 接口不应该设计的大而全 , 一个接口暴露出所有的功能 , 从而使得客户端依赖了自己不需要的接口或者接口的方法 。 而是应该讲接口进行细分和提取 , 而不应该将太过灵活的参数和变量混杂在一个接口中 。
  • D是依赖倒置原则 , A模块依赖于B模块 , B模块有了修改 , 反而要改A , 就是依赖的过于紧密的问题 。 这就是常说的 , 你变了 , 我没变 , 为啥我要改 。 如果基于抽象的接口编程 , 将修改隐藏在后面 , 则能够实现依赖的解耦 。
以上是模块内部常见的设计原则 , 对于模块之间 , 则是对于云原生应用常说的十二要素原则 。

『数据库』微服务化的基石——持续集成
本文插图
八、有关配置文件在代码仓库中 , 还需要管理的是配置文件 , 往往在src/main/resource下面 。
配置的管理原来多使用profile进行管理 , 对于dev, test, production使用不同的配置文件 。
然而当配置非常多的时候 , 比较的痛苦 , 而且配置不断的修改 , 每次上线各种配置需要仔细的核对 , 眼睛都花了 , 才敢上线 。
我们可以将配置分为下面的三类:
  • 内部配置项(启动后不变 , 改变需要重启)
  • 集中配置项(配置中心 , 可动态下发)
  • 外部配置项(外部依赖 , 和环境相关)分页标题
在梳理配置的时候 , 可以按着三类归类 , 分门别类管理 。
在使用了容器之后 , 很多的内部配置项可固化在配置文件中 , 放在容器镜像中 , 需要启动的时候修改的 , 则通过环境变量 , 在启动容器的时候 , 在编排文件中进行修改 。
依赖的内部服务的地址 , 在容器平台kubernetes里面 , 可以通过配置服务名进行服务发现 , 仅仅在配置文件中配置名称就可以了 , 不用配置真实的地址 , kubernetes可以根据不同的环境 , 不同的namespace自动关联好 , 大大简化了配置 。 当然也可以用服务中心Dubbo和Springcloud做内部服务的相互发现 。
依赖的外部服务的地址 , 例如mysql , redis等 , 往往不同的环境不同 , 也可以通过配置kubernetes外部服务名的方式进行 , 而不用一一核对 , 担心测试环境连上了生产环境的IP地址 。
还有一些集中配置项 , 需要动态修改的 , 例如限流 , 降级的开关等 , 需要通过统一的配置中心进行管理 。
九、有关数据库版本代码可以很好的版本化 , 应用也可以用镜像进行原子化的升级和回滚 。
唯一比较难做到的就是数据库如何版本化管理 。
有一个开源工具 flyway 可以比较好的做这件事情 。
在代码中 , flyway需要有以下的结构:
  • 在src/db/migration中有sql文件 , 命名规则 , 如:V1__2017_4_13.sql, V开头+版本号+双下划线+描述 , 后缀为sql
  • 增加flyway的java类 , 实现migration方法
在数据库中 , flyway会自动增加SCHEME_VERSION表 。
当服务启动的时候 , java类的migration方法会被调用 , 它会按照指定路径中sql语句的版本号进行排序并且按照这个排序去执行 , 当每一个sql文件被执行后 , 元数据的表就会按照格式进行更新 。

『数据库』微服务化的基石——持续集成
本文插图
image
当服务重启的时候 , Flyway 再次扫描sql的时候 , 它就会检查元数据表中迁移版本 , 如果要执行的迁移脚本的版本小于或者等于当前版本 , Flyway将会忽略 , 不再重复执行 。
但是flyway从来不解决数据库升级和回滚的代码兼容性问题 。
太多的人问这个问题了 , 代码可以灰度发布 , 数据库咋灰度?代码升级了 , 发现不对可以回滚 , 数据库咋回滚 。
如果可以停服的话 , 自然是使用数据库快照备份的方式进行回滚了 。
如果不可以停服 , 没办法 , 只有在代码层面做兼容性 。 每次涉及数据库升级的都是大事情 , 代码当然应该有个开关 , 保证随时可以切回原来的逻辑 。
【『数据库』微服务化的基石——持续集成】作者:Java微服务链接:https://www.jianshu.com/p/3ce17e089444来源:简书著作权归作者所有 。 商业转载请联系作者获得授权 , 非商业转载请注明出处 。