司库云系统简析

背景

上半年参与一个微服务架构 Web 服务的开发,期间提交了近 2W 行 Java 代码,删除近 1W 行,虽然只是 SaaS 层的代码,但是还是学到了不少东西,写代码方面学了一些 Java 基础和编程规范相关的知识,架构方面了解了一些分布式系统相关的概念,另外又抽时间自学了一些中间件、DevOps(主要是怎么使用 Docker 简化运维)相关的工具。

业务开发总结

我所在的部门偏重业务,就我体验来讲,大家对技术并不会钻研太深,项目经理也认为技术员一招一大批,但是找到懂业务的人才却很难,一方面生活中很少人去接触 To B 的业务,另一方面,项目中用到的业务知识占绝大部分,而技术基本都是由一个平台部门来包装的。
项目开发过程中主要涉及到大型企业投融资方面的业务,比如贷款、发债、担保等,因为公司比较看重保密协议,所以我无法在此分享详细的业务相关的内容,只能分享实现过程中的一些想法。

上游功能实现

司库云中的业务与企业平时填的单据相关,单据之间会有上下游的关系(或者说提供者和消费者),填下游单据前必须填好对应的上游单据(光保存可能还不行,还要先由上级领导审批通过),实际上传统企业都有类似的业务。
实现上游功能有一些注意事项:

  1. 上游需要给下游提供接口,最常见的比如检查上游是否被删除了,因为当下游引用上游到实际保存下游之间会有一段时间,如果在这段时间内上游被删除了,那么这之后再去保存下游就是没有意义的了;
  2. 上游有时需要提供一个特殊的接口,名为“参照”,用于列出可以被引用的上游单据,就像输入一些关键词到百度搜索的搜索框中就会弹出一个下拉菜单显示所有相关的搜索项;
  3. 删除前必须检查是否已经被下游引用过了,一般来说可以直接由下游提供一个接口来实现这种检查,但是对一些下游特别多、而且会越变越多的单据(指的是用户、银行这些经常被参照到的数据),其实更好的实现方式是将这种耦合转移到数据库中,比如用一个 refinfo 表来记录所有参照信息(当然也可以直接把删除操作去了…);
  4. 为下游开发人员提供接口及接口文档,这条是容易被忽视的,因为程序员普遍不喜欢读没有注释的代码和给自己的代码加注释;

下游功能实现

实现下游功能似乎会简单一点,但也不尽然:

  1. 和上游开发沟通接口,最基本的,应该提供所需的返回值格式,而上游则需要说明需要传入哪些参数,另外,这个接口最好是批量的,可以提高效率;
  2. 下游开发者需要对 RMI 远程调用的原理比较熟悉,像调用方法、传参、抛出异常等过程,本质上都是和本地调用不同的。

复杂功能实现

对于一些比较复杂的功能,牵扯到的模块比较多、逻辑比较复杂,做好记录就很重要。写代码最难的不是写代码这个过程,因为只需要基础知识过关、有节操(编码规范),写代码是不难的,难的是写代码前的准备工作,。

  1. 编码前最好是用软件工程中讲过的建模工具进行建模,最常用的如流程图、时序图等;
  2. 就算不想当“画图工程师”、至少也要做好文字记录,最头疼的是见到某些大佬写的 1k 来行的大方法,还没有多少注释。一些人非常自负,觉得“自己的代码自解释、看不懂纯粹是水平不行”。自解释不知道有没有做到,但加注释绝对是对别人负责的做法,因为不是所有人都有时间去看自己写的代码的,而看注释可以更快地理解自己的想法,不至于迷失在代码细节里。
  3. 在用友,其实你不需要懂太多,Java+设计模式+软件工程基础就够了,我在这里学到的最多的应该是软件工程中对风险的控制了:有人懂的话就不要自己瞎想了,有现成的代码就不要自己写了。对于一些复杂的功能,如果之前有人实现过了,那么借鉴一下代码绝对是更划算的做法(还有一个比较阴险的理由是,可以把锅推给人家,所以我觉得抄代码的基本前提是把代码看明白);
  4. 当我们需要写业务代码时,首先要做的第一步应该是和需求进行讨论了,但是很多需求人员其实并不懂代码,他们往往只会从用户的角度对功能进行描述,因此偶尔会碰到沟通困难的情况,这时候可以选择:
    1. 拿流程图和需求进行交流,这样更容易让对方明白自己的脑回路;
    2. 催对方把需求写明白一点(容易打起来);
    3. 拜托其他人去和沟通交流。
      另一方面,如果系统之前已经有类似的功能,首选应该是和相关的开发进行讨论。

报表实现

报表是一种需要从多个表里查数据的功能,根据业务复杂度不同涉及到的表可能达到 7、8 个,如果想用一个 sql 把功能写明白,可以想见这样的 sql 会有多大、多复杂,而且根据编码规范,使用存储过程/函数也是禁止的(因为在云环境下持久层相对业务层更不好扩展)。所以一般是将 sql 拆成多个,在业务代码中进行联结及分页,刚开始实现时我们将数据库里的数据全部查了出来,然后在内存中进行过滤,对几千条数据进行查询就花了超过 10 秒,所以后来研究了一些优化的方法,将速度压到了 1 秒以下:

  1. 批量查询
    据说磁盘 IO 效率(次磁盘访问/秒)和指令执行效率(条指令/秒)之间的比值接近 20W:1,因此用更多的指令执行来取代一次磁盘访问一般是值得的。
    比如需要查某几个班级中的所有同学,我们应该将这些班级的 id 放到一个 in 子句内来批量查询,查出来的数据再在代码里借助 Map 等结构来分出不同班级的同学,因此我们应该写出类似下面的 sql 和代码:
    显示/折叠代码
    1
    select s.id, s.name from student s where s.class in ('c1', 'c2');
    1
    2
    3
    4
    5
    6
    7
    8
    9
    Map<String, List<Student>> stuMap = new HashMap<String, List<Student>>();
    for(Student s : students) {
    List<Student> tmps = stuMap.get(s.getClass());
    if(null == tmps) {
    tmps = new ArrayList<>();
    stuMap.put(s.getClass(), tmps);
    }
    tmps.add(s);
    }
  2. “短路”
    实现内存分页其实没有必要将所有数据都查出来,在查到超过当前页所需要的数据量之后其实就可以直接返回了。
  3. 多线程
    每一行或某几行之间的查询是相互独立的,那么就可以考虑将这些分到多个线程里分别处理。

编码风格

不论是写什么代码,最重要的是风格的统一,所有人最好都使用相同的编码风格,对于容易引起异常的编码方式,可以使用 Sonar、FindBugs、阿里编码规范插件等来进行约束,对于一些不规范的编码风格,则只能由自己主动进行交流纠正(大部分人都会因为怕麻烦而懒得帮别人审查,都只遵守自己心里的那套规范,至少我周围是这样);

自测

功能开发完毕后,一般不能直接提交到 git 仓库上,因为很有可能会存在自己没有料到的 Bug,所以最好自己先根据功能需求的描述进行自测。当测试提交了一个 Bug,为了完整地纠正这个 Bug,最好满足以下条件:

  1. 思考问题原因;
  2. 枚举测试没有考虑到的细节;
  3. 追究相关场景,因为相同的问题可能在其他类似的功能里出现;
  4. 多练练跑步,只要跑得够快,测试就追不上自己。

技术总结

设计模式

  1. 静态工厂方法模式
    通过静态工厂方法来提供对象实例,一方面方法名比构造器可读性更高,而且多了一层包装后,减少了暴露,更容易修改;
    显示/折叠代码
    1
    2
    3
    4
    5
    6
    public static RepayIntstAnalyzer getInstance(...) {
    ...
    }
    public static RepayIntstAnalyzer getInstance4Extend(...) {
    ...
    }
  2. 策略模式
    算法很多时候是复杂的,策略模式是针对算法的优化的一种模式,平时若两种算法具有某些代码是重叠的,我们可能直接将它们写在一块,从软件工程的角度来讲,就是代码内聚性低,比如下面的代码将 A 和 B 算法的代码合到一个方法内部:
    显示/折叠代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    void doAlgo(op) {
    doBefore();
    if(op is A) {
    do A;
    } else {
    do B;
    }
    doAfter();
    }
    为了使得算法的逻辑更加清晰,我们可以将两个算法封装到两个类中,根据用户的输入来传入不同的算法实现类:
    显示/折叠代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    if(srcType == SrcType.REPAY_PRCPL) {
    filter = new RefRepayPrcplFilter();
    } else if(srcType == SrcType.REPAY_INTST) {
    filter = new RefRepayIntstFilter();
    } else if(srcType == SrcType.INTST_ADJUST) {
    filter= new RefIntAdjFilter();
    } else if(srcType == SrcType.LIST) {
    filter = new RefListFlter();
    }
    issueRegisterList = filter.doFilter(issueReigsterList);
    使用策略模式可以减少算法之间的耦合,也可以方便后续加入新的算法,当然坏处是在一定程度上会增加代码冗余度。

微服务

微服务软件将各模块划分到不同的项目中,互相通过 RMI 进行调用,好处是能根据需要来部署模块、利于后期进行扩展,坏处是实现起来复杂很多。应用中,用友希望实现自己的云原生环境,开发了一个类似阿里云的应用实例管理平台,可以调配 CPU、内存、硬盘等资源,当然还有最基础的应用生命周期、配置、日志管理等功能。对其中的代码我没有研究过(部门之间相互隔离,代码是不能直接看到的),不过这些功能我设想可以基于 Docker 开放的 REST 接口来实现,工作量不会小。
既然涉及到微服务的基础设施,就不得不提 Spring Boot 了,Spring Boot 是一个应用启动器,它的主要作用是减少手动配置,其核心是约定大于配置的理念,每个模块都具有默认的功能,只要在 pom.xml 中引入该模块即可使用,除非有特定需求,否则不需要额外的配置。
在司库云中,每个业务项目在 web.xml 配置文件(Spring 配置文件)中,注册了一个 MwClientLoader 类,用于注册所有微服务的基础设施。
司库云是我经历的第一个微服务架构项目,我无法很自信地说哪里好哪里坏,但我也感觉到有很多不合理的地方,比如:

  1. 有现成的就是不用,基于 ZooKeeper 的分布式锁不用 Curator 而是自己写了个客户端,结果后期出现低级 Bug(比如死锁、释放了别的线程占用的锁等);
  2. 将所有工具类(包括中间件的客户端)都写到一个 tmc-utils 项目中,供业务模块依赖,虽然方便,但是导致 tmc-utils 项目颇为臃肿、不稳定,甚至到了后面都没有人知道里面塞了什么东西了;
  3. 将所有实现微服务客户端所需的基础设施写到 MwClientLoader 这个类里来引入,所以司库云其实并没有依赖 Spring Boot;

RMI 远程调用

RMI 是一种将远程调用包装成本地调用形式的技术。在司库云中,即是将接口和参数序列化后通过 HTTP 协议传过去。在毕设《基于微服务的资金云系统发债管理子系统》中我也有按着这种思路自己实现了一种简单的 RMI 框架。
RMI 调用中,框架异常和业务异常默认没有区分,必须得开发自己进行约定,传参只提供对基本类型的转换,如果要传复杂对象,则必须自己将对象属性保存在 Map 中,然后由接收端取出属性还原对象。
服务接口提供者和消费者一般处于独立的两个模块内,这意味着它们会被部署到不同的两个应用实例内,使用 RMI 的方式来相互调用,就像本地方法调用一样,会有方法名、参数、异常抛出等。当时令我们困扰的是如何区分业务抛出的异常和 RMI 框架抛出的异常。要知道所有分布式系统都是建立在不稳定的网络条件之上的,超时这种网络异常是家常便饭。对此我们主要有两种方式:

  1. 使用返回值表示业务异常(接口本身没有返回值的情况下);
    显示/折叠代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    try {
    // ...业务操作
    } catch (Exception e) {
    return "外部放款单出现错误:" + e.getMessage();
    } finally {
    // 释放锁
    infinance.setId(infinance.getInfinancepayid());
    //new VOLockOperaor().unlock(infinance);
    infinance.setId(null);
    }
  2. 分别定义业务异常和 RMI 框架异常,分别捕捉。
    显示/折叠代码
    1
    2
    3
    4
    5
    6
    7
    try {
    // ...业务操作
    } catch (FrameworkException e) {
    // ...处理框架抛出异常
    } catch(BusinessException e) {
    // ...处理业务代码抛出的异常
    }
    当然,不管是哪种方式,最重要的是接口提供方和调用方开发者的统一。

调用方在自测的时候,需要注意接口版本是正确的,有一种情况是,我们并不知道 Bug 是出在自己的代码里还是 RPC 接口里,我们本地只有 RPC 接口的声明,因此普通打断点跟代码的形式就行不通了,我想到的解决办法主要有以下几点:
* 细心编码、滴水不漏,但不是每个人都是 Dijkstra;
* 不厌其烦地调试,在发起远程调用的方法处打断点,将请求参数拦截下来,然后另起一个上游模块实例,使用这些数据调用上游接口,再尝试对 Bug 进行定位;
* 搭建私有化环境,所有关联服务都放到 localhost 里调试,这种方法必须将几乎所有相关项目的配置文件里的 IP 都改成 localhost,动作比较大,而且这样就和微服务没什么关系了;
* 契约测试,将服务消费者与服务提供者解耦
X

RPC 远程调用的开销远高于本地调用,过于滥用会对系统性能造成影响,为了避免这个问题,一方面,在设计之初需要由更懂业务的人参与业务模块的划分,减少远程调用;另一方面,可以考虑使用更高效的通信协议、更高效的通信模式(NIO、AIO)和更高效的序列化/反序列化协议,提高 RPC 协议本身的效率,在分布式系统中,可以说拿下网络协议就是拿下了成功的一半(当然复杂的系统绝不只需要对应用层的考量)。

RPC 涉及到多个独立服务实例间的相互调用,会引入数据一致性风险,我在《基于微服务的资金云系统发债管理子系统》这个课题中也做过初步的探究。

分布式锁

司库云中封装 Curator 实现了一套 ZooKeeper 客户端,利用 apache common pool2 重用客户端,我们在使用期间发现了一些小问题,比如在测试中曾发生过这么个现象:

A 修改保存了一条数据,在加锁后后台逻辑出现问题意外退出了,此时锁并没有释放,而 B 又上来操作了同一条数据,因为锁已经被占用了,因此第一次操作失败了,但是再执行一次操作却正常通过了。

其中一处示例 RPC 调用代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
try {
// 加锁、校验时间戳
this.lock(values);
this.checkTs(values);

// 执行业务操作
...

// 调用审批微服务
service.approve(paralist, message);
try {
// 更新到数据库及执行其他业务操作
...
} catch (Exception ex) {
// 事务补充.防止审批流服务和业务单据的状态不一致
service.unapprove(paralist);
}
} finally {
//释放锁
this.unlock(values);
}

加锁不管成功与否,都会调用 finally 块中的 unlock 方法释放对象的锁,因此会产生上面的现象。解决办法是令 lock 方法调用返回加锁是否成功,只有成功的情况才能调用 unlock。

当测试对数据的批量操作时,还出现过这样的情况:

A 和 B 同时操作 1、2、3 三条数据,二人给出数据的顺序是不确定的,结果两个人的界面都一直在转圈圈(表示正在执行),最后两个人都傻等着直到不耐烦、点了刷新页面。

调用 Curator 接口进行加锁的关键代码如下:

1
((ACLBackgroundPathAndBytesable)client.create().creatingParentsIfNeeded().withMode(CreateMode.PERSISTENT)).forPath("/IUAP_ZKLOCK_ROOT/" + lockPath, String.valueOf(System.currentTimeMillis()).getBytes());

因为锁客户端创建的是一个持久节点,所以在加锁后、且客户端意外退出的还未释放锁的情况下,那个节点就留在 ZooKeeper 集群中了,代码中并没有对这些“野锁”的自动删除功能,所以之后就再也不能对这条数据进行操作了(可能测试人员那会有其他工具,但是这样管理的成本也太高了)。使用 ZooKeeper 实现锁实际上还有其他策略,比如使用临时顺序节点和 watch 机制实现。

显然这是发生死锁的现象,从操作系统的进程调度中我们学过,为了解决死锁问题,主要解决方式是预防死锁(破坏产生死锁的条件)、避免死锁(银行家算法)和检测解除(资源分配图)这几种策略。
以简单起见,我们最后选择破坏一个最显然的产生死锁条件,即资源的占用顺序是不确定的,这样可能会发生 A 先占用 1 再占用 2,而 B 先占用 2 再占用 1,它们发生循环等待,产生死锁。解决办法也比较简单,只要对数据的 ID 统一进行排序后再按顺序进行加锁即可。
另外,加锁操作理应有一个超时时间,不管后台正在进行什么样的操作,“一直转圈圈”相对“没执行完就报了个错”来说,是用户更难以接受的。

涉及同步的代码都是比较难测出隐藏问题的,与其自告奋勇从零开始,不如虚心谨慎采纳现成的方案工具为上,与其将寻找漏洞的任务交给测试,不如在开发时小心谨慎为上。

事务补偿机制

之前讨论过,RPC 涉及到多个独立服务之间的相互调用,可能会发生某一次调用失败然后导致多个数据库之间数据不一致的情况。
如果是单个服务的情况下,可以依赖本地事务的统一提交和回滚,就算把电线忽然拔了,也还是可以用 bin-log 来恢复出错的数据。
在多个服务的情况下,就要依靠分布式事务了。司库云中实现的是最简单但也相当实用的一种,称为事务补偿,示例代码已经在上一节中包含,这里单独给出:

1
2
3
4
5
6
7
8
9
service.approve(vo);
try {
// 更新到数据库及执行其他业务操作
...
} catch (Exception ex) {
// 最好记录日志,供发生意外时手动恢复
// 事务补充.防止审批流服务和业务单据的状态不一致
service.unapprove(paralist);
}
  • 分布式事务和本地事务
    分布式事务必须是建立在本地事务基础之上的,分布式事务只能保证多个服务之间互相调用的原子性,本地事务可以保证本地代码的执行满足 ACID 特性。
  • 事务和异常?
    分布式事务的实现和异常处理机制紧密关联,为了使一次操作尽可能成功,一般出错都需要进行重试,但是网络异常不能保证某次远程调用是否真的成功,所以需要额外考虑某种机制保证接口是幂等的。
  • 事务补偿机制和分布式事务之间的关系。
    事务补偿机制就是对事务链中的任何一个正向操作,都存在一个相应的逆向操作。事务补偿机制是分布式事务的必要组成部分(当然还要有本地事务、幂等性等组件)。

Spring Cloud 后台系统

SpringBoot

SpringBoot 是一个服务启动器,简单地说:

  • 核心思想是“约定大于配置”,即如果要引入某个组件的功能,我们会优先使用该组件的默认配置,比如引入 Tomcat 后默认会监听 8080 端口的请求并将请求传递给 Controller。
    SpringBoot 在原有 Spring 框架的基础上实现了
  • Spring 集成了一些类库,用于简化开发,包括各种中间件的客户端、框架、容器等。

Rest 协议

现在一般的 Web 项目开放的网络接口基本上都会声称是 REST 的,司库云也不例外,但实际上呢?为了方便项目中所有接口的方法都是 POST,链接是一个动词,总之,怎么方便怎么来。
有一次碰到过一个接口偏偏把参数放到请求链接后面(@param()),结果因为参数过长导致服务器读取到的参数被截短了,因为该接口被很多下游功能使用,一改肯定会导致所有功能都出问题,后来没办法只能改 Tomcat 服务器的配置。

日志

异常日志

像日志这种必不可少又特别常用的功能,项目中当然也提供了一个公共接口,其中封装了一些日志框架的常用功能,比如,从最常用的 Log4j 到比较成熟的日志框架 Logback,都免不了的一些像滚动日志、定期删除等功能。

业务日志

业务日志是对所有 Service 接口调用做的日志,虽然后期也没什么用。
其实这种接口等级的日志我觉得可以用于分布式事务出错后的手动回滚,但是这样来说频率及每次所需要记录的内容也会大很多,会对系统性能造成一定影响。