在这里插入图片描述

1.前言

微服务是一种系统架构风格,是面向服务架构(Service Oriented Architecture)的一种实践。

微服务架构通过业务拆分实现服务组件化,通过组件组合快速开发系统,业务单一的服务组件又可以独立部署,使得整个系统变得清晰灵活:

  • 原子服务
  • 独立进程
  • 隔离部署
  • 去中心化服务治理

一个大型复杂的软件应用,可以拆分成多个微服务。各个微服务可被独立部署,之间是松耦合的。现如今后台服务大部分以微服务的形式存在,每个微服务负责实现应用的一个功能模块。微服务又由一个个接口组成,每个接口实现某个功能模块下的子功能。

在这里插入图片描述

以一个 IM 应用为例,它的功能架构可能是下面这样的。

在这里插入图片描述

所以如果是后台开发同学,经常需要实现一个后台微服务来提供相应的能力,完成业务功能。

服务以接口形式提供服务。在实现服务时,我们要将一个大的功能拆分成一个个独立的子功能来实现,每一个子功能就是我们要在服务中实现的一个接口。

有时一个服务会有很多接口,每个接口所要实现的功能可能会有关联,那么这就非常考验设计服务接口的功底,让服务变得简单可靠。

业界已经有很多比较成熟的实践原则,可以帮助我们设计实现出一个可靠易维护的服务。

微服务设计原则并没有严格的规范,本文结合自身后台开发经验,从高可用、高性能、易维护和低风险(安全)角度出发,尝试总结业界常见微服务接口设计原则,帮助大家设计出优秀的微服务。

2.高可用

微服务设计原则——高可用

3.高性能

3.1 无锁

3.1.1 锁的问题

高性能系统中使用锁,往往带来的坏处要大于好处。

并发编程中,锁带解决了安全问题,同时也带来了性能问题,因为锁让并发处理变成了串行操作,所以如无必要,尽量不要显式使用锁。

锁和并发,貌似有一种相克相生的关系。

为了避免严重的锁竞争导致性能的下降,有些场景采用了无锁化设计,特别是在底层框架上。无锁化主要有两种实现,无锁队列和无锁数据结构。

3.1.2 串行无锁

串行无锁最简单的实现方式可能就是单线程模型了,如 Redis 6.0 之前采用了这种方式。但是这种方式利用不了 CPU 多核的优势,所以在网络编程模型中,常用的是单 Reactor 多线程模型。

单 Reactor 多线程模型中,主线程负责处理 I/O 事件,并将读到的数据压入队列,工作线程则从队列中取出数据进行处理,多线程从队列获取数据时需要对队列加锁。如下图所示:

上图的模式可以改成串行无锁的形式,当 MainReactor accept 一个新连接之后从众多的 SubReactor 选取一个进行注册,通过创建一个 Queue 与 I/O 线程进行绑定,此后该连接的读写都在同一个队列和线程中执行,无需对队列加锁。这种模型叫主从 Reactor 多线程模型。

3.1.3 无锁数据结构

利用硬件支持的原子操作可以实现无锁的数据结构,很多语言都提供CAS原子操作(如 Go 中的 atomic 包和 C++11 中的 atomic 库),可以用于实现无锁数据结构,如无锁链表。

我们以一个简单的线程安全单链表的插入操作来看下无锁编程和普通加锁的区别。

template<typename T>
struct Node {
    Node(const T &value) : data(value) {}
    T data;
    Node *next = nullptr;
};

有锁链表 WithLockList:

template<typename T>
class WithLockList {
    mutex mtx;
    Node<T> *head;
public:
    void pushFront(const T &value) {
        auto *node = new Node<T>(value);
        lock_guard<mutex> lock(mtx); // (1)
        node->next = head;
        head = node;
    }
};

无锁链表 LockFreeList:

template<typename T>
class LockFreeList {
    atomic<Node<T> *> head;
public:
    void pushFront(const T &value) {
        auto *node = new Node<T>(value);
        node->next = head.load();
        while(!head.compare_exchange_weak(node->next, node)); // (2)
    }
};

从代码可以看出,在有锁版本中 (1) 进行了加锁。在无锁版本中,(2) 使用了原子 CAS 操作 compare_exchange_weak,该函数如果存储成功则返回 true,同时为了防止伪失败(即原始值等于期望值时也不一定存储成功,主要发生在缺少单条比较交换指令的硬件机器上),通常将 CAS 放在循环中。

下面对有锁和无锁版本进行简单的性能比较,分别执行 1000,000 次push操作。测试代码如下:

int main() {
    const int SIZE = 1000000;
    //有锁测试
    auto start = chrono::steady_clock::now();
    WithLockList<int> wlList;
    for(int i = 0; i < SIZE; ++i)
    {
        wlList.pushFront(i);
    }
    auto end = chrono::steady_clock::now();
    chrono::duration<double, std::micro> micro = end - start;
    cout << "with lock list costs micro:" << micro.count() << endl;

    //无锁测试
    start = chrono::steady_clock::now();
    LockFreeList<int> lfList;
    for(int i = 0; i < SIZE; ++i)
    {
        lfList.pushFront(i);
    }
    end = chrono::steady_clock::now();
    micro = end - start;
    cout << "free lock list costs micro:" << micro.count() << endl;

    return 0;
}

三次输出如下,可以看出无锁版本有锁版本性能高一些。

with lock list costs micro:548118
free lock list costs micro:491570
with lock list costs micro:556037
free lock list costs micro:476045
with lock list costs micro:557451
free lock list costs micro:481470

3.1.4 减少锁竞争

如果加锁无法避免,则可以采用分片的形式,减少对资源加锁的次数,这样也可以提高整体的性能。

比如 Golang 优秀的本地缓存组件 bigcachego-cachefreecache 都实现了分片功能,每个分片一把锁,采用分片存储的方式减少加锁的次数从而提高整体性能。

以一个简单的示例,通过对map[uint64]struct{}分片前后并发写入的对比,来看下减少锁竞争带来的性能提升。

var (
	num = 1000000
	m0  = make(map[int]struct{}, num)
	mu0 = sync.RWMutex{}
	m1  = make(map[int]struct{}, num)
	mu1 = sync.RWMutex{}
)

// ConWriteMapNoShard 不分片写入一个 map。
func ConWriteMapNoShard() {
	g := errgroup.Group{}
	for i := 0; i < num; i++ {
		g.Go(func() error {
			mu0.Lock()
			defer mu0.Unlock()
			m0[i] = struct{}{}
			return nil
		})
	}
	_ = g.Wait()
}

// ConWriteMapTwoShard 分片写入两个 map。
func ConWriteMapTwoShard() {
	g := errgroup.Group{}
	for i := 0; i < num; i++ {
		g.Go(func() error {
			if i&1 == 0 {
				mu0.Lock()
				defer mu0.Unlock()
				m0[i] = struct{}{}
				return nil
			}
			mu1.Lock()
			defer mu1.Unlock()
			m1[i] = struct{}{}
			return nil
		})
	}
	_ = g.Wait()
}

看下二者的性能差异:

func BenchmarkConWriteMapNoShard(b *testing.B) {
	for i := 0; i < b.N; i++ {
		ConWriteMapNoShard()
	}
}
BenchmarkConWriteMapNoShard-12                 3         472063245 ns/op

func BenchmarkConWriteMapTwoShard(b *testing.B) {
	for i := 0; i < b.N; i++ {
		ConWriteMapTwoShard()
	}
}
BenchmarkConWriteMapTwoShard-12                4         310588155 ns/op

可以看到,通过对分共享资源的分片处理,减少了锁竞争,能明显地提高程序的并发性能。可以预见的是,随着分片粒度地变小,性能差距会越来越大。当然,分片粒度不是越小越好。因为每一个分片都要配一把锁,那么会带来很多额外的不必要的开销。可以选择一个不太大的值,在性能和花销上寻找一个平衡。

3.2 缓存

微服务设计原则——高性能:缓存

3.3 异步

3.3.1 调用异步

调用异步发生在使用异步编程模型来提高代码效率的时候,实现方式主要有:

  • Callback

异步回调通过注册一个回调函数,然后发起异步任务,当任务执行完毕时会回调用户注册的回调函数,从而减少调用端等待时间。这种方式会造成代码分散难以维护,定位问题也相对困难;

  • Future

当用户提交一个任务时会立刻先返回一个Future,然后任务异步执行,后续可以通过Future获取执行结果;

可以对多个异步编程进行编排,组成更复杂的异步处理,并以同步的代码调用形式实现异步效果。CPS 将后续的处理逻辑当作参数传递给 Then 并可以最终捕获异常,解决了异步回调代码散乱和异常跟踪难的问题。Java 中的 CompletableFuture 和 C++ PPL基本支持这一特性。典型的调用形式如下:

void handleRequest(const Request &req) {
  return req.Read().Then([](Buffer &inbuf){
      return handleData(inbuf);
  }).Then([](Buffer &outbuf){
      return handleWrite(outbuf);
  }).Finally(){
      return cleanUp();
  });
}

关于 CPS 更多信息推荐阅读:2018 中国 C++ 大会的吴锐_C++服务器开发实践部分

3.3.2 流程异步

同步改异步,可以降低主链路的处理耗时。

举个例子,比如我们去 KFC 点餐,遇到排队的人很多,当点完餐后,大多情况下我们会隔几分钟就去问好了没,反复去问了好几次才拿到,在这期间我们也没法干活了。

这个就叫同步轮训,这样效率显然太低了。

服务员被问烦了,就在点完餐后给我们一个号码牌,每次准备好了就会在服务台叫号,这样我们就可以在被叫到的时候再去取餐,中途可以继续干自己的事。这就叫异步。

3.4 池化

3.4.1 为什么要池化

池化的目的是完成资源复用,避免资源重复创建、删除来提高性能。

常见的池子有内存池、连接池、线程池、对象池…

内存、连接、线程、对象等都是资源,创建和销毁这些资源都有一个特征, 那就是会涉及到很多系统调用或者网络 IO。 每次都在请求中去创建这些资源,会增加处理耗时,但是如果我们用一个 容器(池) 把它们保存起来,下次需要的时候,直接拿出来使用,避免重复创建和销毁浪费的时间。

3.4.1 内存池

我们都知道,在 C/C++ 中分别使用 malloc/free 和 new/delete 进行内存的分配,其底层调用系统调用 sbrk/brk。频繁的调用系统调用分配释放内存不但影响性能还容易造成内存碎片,内存池技术旨在解决这些问题。正是这些原因,C/C++ 中的内存操作并不是直接调用系统调用,而是已经实现了自己的一套内存管理,malloc 的实现主要有三大实现。

  • ptmalloc:glibc的实现。
  • tcmalloc:Google的实现。
  • jemalloc:Facebook的实现。

虽然标准库的实现在操作系统内存管理的基础上再加了一层内存管理,但应用程序通常也会实现自己特定的内存池,如为了引用计数或者专门用于小对象分配。所以看起来内存管理一般分为三个层次。

3.4.2 线程池

线程创建是需要分配资源的,这存在一定的开销,如果我们一个任务就创建一个线程去处理,这必然会影响系统的性能。线程池的可以限制线程的创建数量并重复使用,从而提高系统的性能。

线程池可以分类或者分组,不同的任务可以使用不同的线程组,可以进行隔离以免互相影响。对于分类,可以分为核心和非核心,核心线程池一直存在不会被回收,非核心可能对空闲一段时间后的线程进行回收,从而节省系统资源,等到需要时在按需创建放入池子中。

3.4.3 连接池

常用的连接池有数据库连接池、redis连接池、TCP连接池等等,其主要目的是通过复用来减少创建和释放连接的开销。连接池实现通常需要考虑以下几个问题:

  • 初始化:启动即初始化和惰性初始化。启动初始化可以减少一些加锁操作和需要时可直接使用,缺点是可能造成服务启动缓慢或者启动后没有任务处理,造成资源浪费。惰性初始化是真正有需要的时候再去创建,这种方式可能有助于减少资源占用,但是如果面对突发的任务请求,然后瞬间去创建一堆连接,可能会造成系统响应慢或者响应失败,通常我们会采用启动即初始化的方式。

  • 连接数目:权衡所需的连接数,连接数太少则可能造成任务处理缓慢,太多不但使任务处理慢还会过度消耗系统资源。

  • 连接取出:当连接池已经无可用连接时,是一直等待直到有可用连接还是分配一个新的临时连接。

  • 连接放入:当连接使用完毕且连接池未满时,将连接放入连接池(包括连接池已经无可用连接时创建的临时连接),否则关闭。

  • 连接检测:长时间空闲连接和失效连接需要关闭并从连接池移除。常用的检测方法有:使用时检测和定期检测。

3.4.4 对象池

严格来说,各种池都是对象池的的具体应用,包括前面介绍的三种池。

对象池跟各种池一样,也是缓存一些对象从而避免大量创建同一个类型的对象,同时限制了实例的个数。如 Redis 中 0-9999 整数对象就通过对象池进行共享。在游戏开发中对象池经常使用,如进入地图时怪物和 NPC 的出现并不是每次都是重新创建,而是从对象池中取出。

3.5 批量

能批量就不要并发。

如果调用方需要调用我们接口多次才能进行一个完整的操作,那么这个接口设计就可能有问题。

比如获取数据的接口,如果仅仅提供getData(int id)接口,那么使用方如果要一次性获取 20 个数据,它就需要循环遍历调用我们接口 20 次,不仅使用方性能很差,也无端增加了我们服务的压力,这时提供一个批量拉取的接口getDataBatch(List<Integer> idList)显然是必要的。

对于批量接口,我们也要注意接口的吞吐能力,避免长时间执行。

还是以获取数据的接口为例:getDataList(List<Integer> idList),假设一个用户一次传 1w 个id进来,那么接口可能需要很长的时间才能处理完,这往往会导致超时,用户怎么调用结果都是超时异常,那怎么办?限制长度,比如限制长度为 100,即每次最多只能传 100 个id,这样就能避免长时间执行,如果用户传的 id 列表长度超过 100 就报异常。

加了这样限制后,必须要让使用方清晰地知道这个方法有此限制,尽可能地避免用户误用。

有三种方法:

  • 改变方法名,比如getDataListWithLimitLength(List<Integer> idList)
  • 在接口说明文档中增加必要的注释说明;
  • 接口明确抛出超长异常,直白告知主调。

3.6 并发

3.6.1 请求并发

如果一个任务需要处理多个子任务,可以将没有依赖关系的子任务并发化,这种场景在后台开发很常见。如一个请求需要查询 3 个数据,分别耗时T1、T2、T3,如果串行调用总耗时 T=T1+T2+T3。对三个任务执行并发,总耗时 T=max(T1,T 2,T3)。同理,写操作也如此。对于同种请求,还可以同时进行批量合并,减少 RPC 调用次数。

3.6.2 冗余请求

冗余请求指的是同时向后端服务发送多个同样的请求,谁响应快就是使用谁,其他的则丢弃。这种策略缩短了主调方的等待时间,但也使整个系统调用量猛增,一般适用于初始化或者请求少的场景。比如腾讯公司 WNS 的跑马模块其实就是这种机制,跑马模块为了快速建立长连接同时向后台多个 IP/Port 发起请求,谁快就用谁,这在弱网的移动设备上特别有用,如果使用等待超时再重试的机制,无疑将大大增加用户的等待时间。

这种方式较少使用,知道即可。

3.7 存储设计

任何一个系统,从单机到分布式,从前端到后台,功能和逻辑各不相同,但干的只有两件事:读和写。而每个系统的业务特性可能都不一样,有的侧重读、有的侧重写,有的两者兼备,本节主要探讨在不同业务场景下存储读写的一些方法论。

3.7.1 读写分离

大多数业务都是读多写少,为了提高系统处理能力,可以采用读写分离的方式将主节点用于写,从节点用于读,如下图所示。

读写分离架构有以下几个特点:
(1)数据库服务为主从架构;
(2)主节点负责写操作,从节点负责读操作;
(3)主节点将数据复制到从节点;

基于读写分离思想,可以设计出多种主从架构,如主-主-从、主-从-从等。主从节点也可以是不同的存储,如MySQL+Redis。

读写分离的主从架构一般采用异步复制,会存在数据复制延迟的问题,适用于对数据一致性要求不高的业务。可采用以下几个方式尽量避免复制滞后带来的问题。

  • 写后读一致

即读自己的写,适用于用户写操作后要求实时看到更新。典型的场景是,用户注册账号或者修改账户密码后,紧接着登录,此时如果读请求发送到从节点,由于数据可能还没同步完成,用户登录失败,这是不可接受的。针对这种情况,可以将自己的读请求发送到主节点上,查看其他用户信息的请求依然发送到从节点。

  • 二次读取

优先读取从节点,如果读取失败或者跟踪的更新时间小于某个阀值,则再从主节点读取。

  • 区分场景

关键业务读写主节点,非关键业务读写分离。

  • 单调读

保证用户的读请求都发到同一个从节点,避免出现回滚的现象。如用户在 M 主节点更新信息后,数据很快同步到了从节点 S1,用户查询时请求发往 S1,看到了更新的信息。接着用户再一次查询,此时请求发到数据同步没有完成的从节点 S2,用户看到的现象是刚才的更新的信息又消失了,即以为数据回滚了。

3.7.2 分库分表

读写分离虽然可以明显的提示查询的效率,但是无法解决更高的并发写入请求的场景,这时候就需要进行分库分表,提高并发写入的能力。

通常,在以下情况下需要进行分库分表:

(1)单表的数据量达到了一定的量级(如 MySQL 一般为千万级),读写的性能会下降。这时索引也会很大,性能不佳,需要分解单表。

(2)数据库吞吐量达到瓶颈,需要增加更多数据库实例来分担数据读写压力。

分库分表按照特定的条件将数据分散到多个数据库和表中,分为垂直切分和水平切分两种模式。

  • 垂直切分

按照一定规则,如业务或模块类型,将一个数据库中的多个表分布到不同的数据库上。以电商平台为例,将商品数据、订单数据、用户数据分别存储在不同的数据库上,如下图所示:

优点:
(1)切分规则清晰,业务划分明确;
(2)可以按照业务的类型、重要程度进行成本管理,扩展也方便;
(3)数据维护简单。

缺点:
(1)不同表分到了不同的库中,无法使用表连接Join。不过在实际的业务设计中,也基本不会用到 Join 操作,一般都会建立映射表通过两次查询或者写时构造好数据存到性能更高的存储系统中。
(2)事务处理复杂,原本在事务中操作同一个库的不同表不再支持。这时可以采用柔性事务或者其他分布式事物方案。

  • 水平切分

按照一定规则,如哈希或取模,将同一个表中的数据拆分到多个数据库上。可以简单理解为按行拆分,拆分后的表结构是一样的。如用户信息记录,日积月累,表会越来越大,可以按照用户 ID 或者用户注册日期进行水平切分,存储到不同的数据库实例中。

优点:
(1)切分后表结构一样,业务代码不需要改动;
(2)能控制单表数据量,有利于性能提升。

缺点:
(1)Join、count、记录合并、排序、分页等问题需要跨节点处理;
(2)相对复杂,需要实现路由策略;

综上所述,垂直切分和水平切分各有优缺点,通常情况下这两种模式会一起使用。

3.7.3 动静分离

动静分离将经常更新的数据和更新频率低的数据进行分离。最常见于 CDN,一个网页通常分为静态资源(图片/JS/CSS等)和动态资源(JSP、PHP等),采取动静分离的方式将静态资源缓存在 CDN 边缘节点上,只需请求动态资源即可,减少网络传输和服务负载。

在数据库和 KV 存储上也可以采取动态分离的方式。动静分离更像是一种垂直切分,将动态和静态的字段分别存储在不同的库表中,减小数据库锁的粒度,同时可以分配不同的数据库资源来合理提升利用率。

3.7.4 冷热分离

冷热分离可以说是每个存储产品和海量业务的必备功能,MySQL、ElasticSearch 等都直接或间接支持冷热分离。将热数据放到性能更好的存储设备上,冷数据下沉到廉价的磁盘,从而节约成本。

3.7.5 重写轻读

基本思路就是写入数据时多写点(冗余写),降低读的压力。

社交平台中用户可以互相关注,查看关注用户的最新消息,形成 Feed 流。

用户查看 Feed 流时,系统需要查出此用户关注了哪些用户,再查询这些用户所发的消息,按时间排序。

为了满足高并发的查询请求,可以采用重写轻读,提前为每个用户准备一个收件箱。

每个用户都有一个收件箱和一个发件箱。比如一个用户有1000个粉丝,他发布一条消息时,写入自己的发件箱即可,后台异步的把这条消息放到那1000个粉丝的收件箱中。

这样,用户读取 Feed 流时就不需要实时查询聚合了,直接读自己的收件箱就行了。把计算逻辑从”读”移到了”写”一端,因为读的压力要远远大于写的压力,所以可以让”写”帮忙干点活儿,提升整体效率。

上面展示了一个重写轻度的一个例子,在实际应用中可能会遇到一些问题。如:

(1)写扩散:这是个写扩散的行为,如果一个大 V 的粉丝很多,这写扩散的代价也是很大的,而且可能有些人万年不看朋友圈甚至屏蔽了朋友。需要采取一些其他的策略,如粉丝数在某个范围内是才采取这种方式,数量太多采取推拉结合和分析一些活跃指标等。

(2)信箱容量:一般来说查看 Feed 流(如微信朋友圈)不会不断地往下翻页查看,这时候应该限制信箱存储条目数,超出的条目从其他存储查询。

3.7.6 数据异构

数据异构顾名思义就是存储不同结构的数据,有很多种含义:

  • 数据格式的异构

数据的存储格式不同,可以是关系型(如 MySQL、SQL Server、DB2等),也可以是 KV 格式(如 Redis、Memcache等),还可以是文件行二维数据(如 txt、CSV、XLS等)。

  • 数据存储地点的异构

据存储在分散的物理位置上,此类情况大多出现在大型机构中,如销售数据分别存储在北京、上海、日本、韩国等多个分支机构的本地销售系统中。

  • 数据存储逻辑的异构

相同的数据按照不同的逻辑来存储,比如按照不同索引维度来存储同一份数据。

这里主要说的是按照不同的维度建立索引关系以加速查询。如京东、天猫等网上商城,一般按照订单号进行了分库分表。由于订单号不在同一个表中,要查询一个买家或者商家的订单列表,就需要查询所有分库然后进行数据聚合。可以采取构建异构索引,在生成订单的时同时创建买家和商家到订单的索引表,这个表可以按照用户 ID 进行分库分表。

3.8 零拷贝

3.8.1 为什么要实现零拷贝?

这里的拷贝指的是数据在内核缓冲区和应用程序缓冲区直接的传输,并非指进程空间中的内存拷贝(当然这方面也可以实现零拷贝,如传引用和 C++ 中 move 操作)。现在假设我们有个服务,提供用户下载某个文件,当请求到来时,我们把服务器磁盘上的数据发送到网络中,这个流程伪代码如下:

filefd = open(...); //打开文件
sockfd = socket(...); //打开socket
buffer = new buffer(...); //创建buffer
read(filefd, buffer); //从文件内容读到buffer中
write(sockfd, buffer); //将buffer中的内容发送到网络

数据拷贝流程如下图:

上图中绿色箭头表示 DMA copy,DMA(Direct Memory Access)即直接存储器存取,是一种快速传送数据的机制,指外部设备不通过 CPU而直接与系统内存交换数据的接口技术。红色箭头表示 CPU copy。即使在有 DMA 技术的情况下还是存在 4 次拷贝,DMA copy 和 CPU copy 各 2 次。

3.8.2 内存映射

内存映射将用户空间的一段内存区域映射到内核空间,用户对这段内存区域的修改可以直接反映到内核空间,同样,内核空间对这段区域的修改也直接反映用户空间,简单来说就是用户空间共享这个内核缓冲区。

使用内存映射来改写后的伪代码如下:

filefd = open(...); //打开文件
sockfd = socket(...); //打开socket
buffer = mmap(filefd); //将文件映射到进程空间
write(sockfd, buffer); //将buffer中的内容发送到网络

使用内存映射后数据拷贝流如下图所示:

从图中可以看出,采用内存映射后数据拷贝减少为 3 次,不再经过应用程序直接将内核缓冲区中的数据拷贝到 Socket 缓冲区中。 RocketMQ 为了消息存储高性能,就使用了内存映射机制,将存储文件分割成多个大小固定的文件,基于内存映射执行顺序写。

3.8.3 零拷贝

零拷贝就是一种避免 CPU 将数据从一块存储拷贝到另外一块存储,从而有效地提高数据传输效率的技术。Linux 内核 2.4 以后,支持带有 DMA 收集拷贝功能的传输,将内核页缓存中的数据直接打包发到网络上,伪代码如下:

filefd = open(...); //打开文件
sockfd = socket(...); //打开socket
sendfile(sockfd, filefd); //将文件内容发送到网络

使用零拷贝后流程如下图:

零拷贝的步骤为:

(1)DMA 将数据拷贝到 DMA 引擎的内核缓冲区中。
(2)将数据的位置和长度的信息的描述符加到套接字缓冲区。
(3)DMA 引擎直接将数据从内核缓冲区传递到协议引擎。

可以看出,零拷贝并非真正的没有拷贝,还是有 2 次内核缓冲区的 DMA 拷贝,只是消除了内核缓冲区和用户缓冲区之间的 CPU 拷贝。Linux 中主要的零拷贝系统函数有 sendfile、splice、tee 等。零拷贝比普通传输会快很多,如 Kafka 也使用零拷贝技术。

下图是来住 IBM 官网上普通传输和零拷贝传输的性能对比,可以看出零拷贝比普通传输快了 3 倍左右。

在这里插入图片描述

4.易维护

4.1 充分必要

不是随便一个功能就要有个接口。

虽然一个接口应该只专注一件事,但并不是每一个功能都要新建一个接口。要有充分的理由和考虑,即这个接口的存在是十分有意义和价值的。无意义的接口不仅浪费开发人力,更增加了服务的维护难度,服务将会十分臃肿。

相关功能我们应该考虑合为一个接口来实现。

4.2 单一职责

每个 API 应该只专注做一件事情。

就像我们开发人员一样,要么从事后台开发,要么从事前端开发,要么从事服务器运维开发。公司一般不会让一个人包揽所有的开发工作,因为这让员工的职责不够单一,不利于员工在专业领域的深耕,很容易成为万金油。对公司的影响是因员工对专业知识掌握的不够深,导致开发出的软件质量得不到保证。

让接口的功能保持单一,实现起来不仅简单,维护起来也会容易很多,不会因为大而全的冗杂功能导致接口经常出错。

比如读写分离和动静分离的做法都是单一职责原则的具体体现。如果一个接口干了两件事情,就应该把它分开,因为修改一个功能可能会影响到另一个功能。

4.3 内聚解耦

一个接口要包含完整的业务功能,而不同接口之间的关联要尽可能的小。

这样便降低了对其他接口的依赖程度,如此其他接口的变动对当前接口的影响也会降低。一般都是通过消息中间件 MQ 来完成接口之间的耦合。

4.4 开闭原则

对扩展开放,对修改关闭。

这句话怎么理解呢,也就是说,我们在设计一个接口的时候,应当使这个接口可以在不被修改的前提下被扩展其功能。换句话说,应当可以在不修改源代码的情况下改变接口的行为。

比如 IM 应用中,当用户输入简介时有个长度限制,我们不应该将长度限制写死在代码,可以通过配置文件的方式来动态扩展,这就做到了对扩展开放(用户简介长度可以变更),对修改关闭(不需要修改代码)。

此外,在设计模式中模板方法模式和观察者模式都是开闭原则的极好体现。

4.5 统一原则

接口要具备统一的命名规范、统一的出入参风格、统一的异常处理流程、统一的错误码定义、统一的版本规范等。

统一规范的接口有很多优点,自解释、易学习,难误用,易维护等。

4.6 用户重试

接口失败时,应该尽可能地由用户重试。

失败不可避免,因为接口无法保证100%成功。一个简单可靠的异常处理策略便是由用户重试,而不是由后台服务进行处理。

还是 IM 应用为例,有这样的需求场景。群管理员需要拉黑用户,被拉黑的用户要先剔出群,且后续不允许加入群。那么拉黑由一个独立的接口来完成,需要两个操作。一是将用户剔出群,二是将用户写入群的黑名单存储。此时两个操作无法做到事务,也就是我们无法保证两个操作要么同时成功,要么同时失败。

这种情况下我们该怎么做,既让接口实现起来简单,也能满足需求呢?

我们如果将用户剔出群放到第一步,那么可能会存在踢出群成功,但是写入群的黑名单存储失败,这种情况下提示用户拉黑失败,但却把用户给踢出了群,对用户来说,体验上是个功能 bug。

秉着尽可能地由用户重试的原则,我们应该将写入群的黑名单存储放到第一步,踢出群放到第二步。并且踢出群作为非关键逻辑,允许失败,因为者可以让用户手动将该用户踢出群,这就给了用户重试的机会,并且我们的接口在实现上也变得简单。

如果要引入消息队列存储踢出群的失败日志,让后由后台服务消费重试来保证一定成功,那么实现上将变得复杂且难以维护。不是非常重要的操作,一定不要这么做。

4.7 最小惊讶

代码应该尽可能避免让读者蒙圈。

只需根据需求来设计实现即可,切勿刻意去设计一个复杂无用、华而不实的 API,以免弄巧成拙。一个通俗易懂易维护的 API 比一个炫技复杂难理解的 API 更容易让人接受。

4.8 避免无效请求

不要传递无效请求至下游。

无效请求下游应及早检测发现并拒绝,可能会引发相关入参无效的告警,混淆视听且骚扰。我们应避免传递无效请求至下游,避免浪费带宽和计算资源。

换位思考,谁都不想浪费力气做无用功。

4.9 入参校验

自己收到的请求要做好入参校验,及早发现无效请求并拒绝,然后告警。发现垃圾请求后推动上游不要传递无效请求至下游。

此时,我们是上游的下游,做好入参校验,避免做无用功。

4.10 设计模式

适当的使用设计模式,让我们的代码更加简洁、易读、可扩展。

设计模式(Design Pattern)是一套被反复使用、多人知晓、分类编目、代码设计经验的总结。使用设计模式可以带来如下益处。

  • 简洁。比如单例模式,减少多实例创建维护的成本,获取实例只需要一个 Get 函数。
  • 易读。业界经验,多人知晓。如果告知他人自己使用了相应的设计模式实现某个功能,那么他人便大概知晓了你的实现细节,更加容易读懂你的代码。
  • 可扩展。设计模式不仅能简洁我们的代码,还可以增加代码的可扩展性。比如 Go 推崇的 Option 模式,既避免了书写不同参数版本的函数,又达到了无限扩增函数参数的效果,增加了函数扩展性。

4.11 禁用 flag 标识

为什么接口不要使用 flag 标识,因为这会使接口变得臃肿,违背单一职责,最终难以维护。

这里说下,我们为什么会使用 flag 标识。

有时,我们需要提供一个读接口供上游调用查询相关信息。如主调 A 需要信息 a,主调 B 需要信息 b,主调 C 需要信息 c,主调 D 需要信息 a 和 b。如果为每个主调获取信息都提供单独的接口,那么接口会变得很多。为了减少接口的数量,我们很容易想到给接口增加多个 flag 参数,每个主调在调用接口时携带不同的 flag,表明需要获取哪些信息,然后接口根据入参 flag 获取对应的信息。比如主调 A 调用时将 flag_a 置为 true,主调 B 将 flag_b 置为 true,主调 C 将 flag_c 置为 true,主调 D 将 flag_a 和 flag_c 置为 true。

在项目前期或者 flag 数量较少的情况下,接口功能不是很多时,一般不会暴露出问题。一但开了这个口子,随着需要不同信息主调的增多,接口会不停的增加 flag,最终导致接口变得庞大臃肿,不仅难以阅读维护,还会使接口性能低下。

所以,我们应该禁用 flag 标识,尽可能地保证接口功能单一。

回到上面提到的场景,不适用 flag 标识,我们改如何是好呢?

我们应该坚持单一职责的原则,将信息进行原子分割,每个原子信息作为一个独立的接口对外提供服务。如果需要多个原子信息,我们可以增加一个 proxy 层,以独立接口将需要的相关原子信息汇聚组合。这么做你可能会问,接口变多了,会导致服务难以维护。不用担心,如果服务接口数量过多,我们应该对服务进行拆分。

还是以上面提及的例子为例,接口禁用 flag 前后组织形式对比如下:

4.12 分页宜小不宜大

对于查询 API 来说,当查询结果集包含成千上万条记录时,返回所有结果是一个挑战,它给服务器、客户端和网络带来了不必要的压力,于是便有了分页接口。

通常我们通过 page 和 size(offset 和 limit)或 after_id 和 limit 实现分页。page 与 size 适合数据总量小的浅分页查询,after_id 和 limit 适合数据总量大的深分页查询。

在设计分页接口时,页大小需要设置上限。这是因为如果没有上限,客户端可以请求任意大的页大小,从而可能导致服务器性能问题,例如一次请求返回过多数据,导致服务器响应变慢,网络传输时间变长,甚至可能引起系统崩溃等问题。

为了防止这种情况的发生,通常会在设计分页接口时设置一个最大页大小限制。当客户端请求的页大小超过最大限制时,应该向客户端返回一个错误提示,告知客户端页大小超过最大限制,建议客户端减小页大小,以保证服务器和客户端的正常运行。

那么页大小设为多少合适呢?

常见的页大小有 10,20,50,100,500 和 1000。如何选择页大小,我们应该在满足特定业务场景需求下,宜小不宜大。

太大的页,主要有以下几个问题:

  • 影响用户体验。页太大,加载会比较慢,用户等待时间会比较长。
  • 影响接口性能。页太大,会增加数据的拉取编解码耗时,降低接口性能。
  • 浪费带宽。很多场景下,用户在浏览的过程中,不会看完一页中的所有数据,返回太大的页是一种浪费。
  • 扩展性差。随着业务的发展,接口在页大小不变的情况下,返回的页数据可能会越来越大,导致接口性能越来越差,最终拖垮接口。

页大小多少合适,没有标准答案,需要根据具体的业务场景来定。但是要坚持一点,页宜小不宜大。如果页大小能用 10 便可满足业务需求,就不要用 20,更不要用 50。

5.低风险

微服务设计原则——低风险

6.小结

好的服务是设计出来的,而不是维护出来的。

优秀的设计原则告诉我们如何写出好的服务来应对千变万化的业务场景。

所有事物都不是 100% 可靠的,服务亦是如此,但遵守优秀的设计原则让我们的服务距离 100% 可靠更近一步。


参考文献

Google Cloud API Desgin Guide
知乎.怎么理解软件设计中的开闭原则?
一文搞懂后台高性能服务器设计的常见套路, BAT 高频面试系列
mmap详解
Cache Usage Patterns - Ehcache
缓存模式

Logo

开源、云原生的融合云平台

更多推荐