文章目录

  • 1.前言
  • 2.高可用
    • 2.1 降级兜底
    • 2.2 过载保护(保护自己)
    • 2.3 流量控制(保护下游)
    • 2.4 快速失败
    • 2.5 无状态服务
    • 2.6 最少依赖
    • 2.7 简单可靠
    • 2.8 分散原则
    • 2.9 隔离原则
    • 2.10 幂等设计(可重入)
    • 2.11 故障自愈
    • 2.12 CAP 原则
  • 3.高性能
    • 3.1 无锁
      • 3.1.1 锁的问题
      • 3.1.2 串行无锁
      • 3.1.3 无锁数据结构
      • 3.1.4 减少锁竞争
    • 3.2 缓存
      • 3.2.1 为什么要有缓存?
      • 3.2.2 缓存的使用场景
      • 3.2.3 缓存的分类?
      • 3.2.4 缓存的使用模式
      • 3.2.5 缓存淘汰策略
      • 3.2.6 缓存的崩溃与修复
      • 3.2.7 缓存的一些好实践
    • 3.3 异步
      • 3.3.1 调用异步
      • 3.3.2 流程异步
    • 3.4 池化
      • 3.4.1 为什么要池化
      • 3.4.1 内存池
      • 3.4.2 线程池
      • 3.4.3 连接池
      • 3.4.4 对象池
    • 3.5 批量
    • 3.6 并发
      • 3.6.1 请求并发
      • 3.6.2 冗余请求
    • 3.7 存储设计
      • 3.7.1 读写分离
      • 3.7.2 分库分表
      • 3.7.3 动静分离
      • 3.7.4 冷热分离
      • 3.7.5 重写轻读
      • 3.7.6 数据异构
    • 3.8 零拷贝
  • 4.易维护
    • 4.1 充分必要
    • 4.2 单一职责
    • 4.3 内聚解耦
    • 4.4 开闭原则
    • 4.5 统一原则
    • 4.6 用户重试
    • 4.7 最小惊讶
    • 4.8 避免无效请求
    • 4.9 入参校验
    • 4.10 设计模式
  • 5.小结
  • 参考文献

1.前言

现如今后台服务大部分以微服务的形式存在,每个微服务负责实现应用的一个功能模块。而微服务是由一个个接口组成,每个接口实现某个功能模块下的子功能。

微服务是一种系统架构风格。一个大型复杂的软件应用,由一个或多个微服务组成。系统中的各个微服务可被独立部署,各个微服务之间是松耦合的。

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


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

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

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

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

微服务设计原则并没有严格的规范,下面结合业界成熟的方法和个人多年后台开发经验,介绍高可用,高性能,易维护服务常用的设计原则。

2.高可用

2.1 降级兜底

大部分服务是如下的结构,既要给使用方使用,又依赖于他人提供的第三方服务,中间又穿插了各种业务逻辑,这里每一块都可能是故障的来源。

如果第三方服务挂掉怎么办?我们业务也跟着挂掉?显然这不是我们希望看到的结果,如果能制定好降级兜底的方案,那将大大提高服务的可靠性。

比如我们做个性化推荐服务时,需要从用户中心获取用户的个性化数据,以便代入到模型里进行打分排序,但如果用户中心服务挂掉,我们获取不到数据了,那么就不推荐了?显然不行,我们可以在cache里放置一份热门商品以便兜底;

又比如做一个数据同步的服务,这个服务需要从第三方获取最新的数据并更新到 MySQL 中,恰好第三方提供了两种方式:

  • 一种是消息通知服务,只发送变更后的数据;
  • 一种是 HTTP 服务,需要我们自己主动调用获取数据。

我们一开始选择消息同步的方式,因为实时性更高,但是之后就遭遇到消息迟迟发送不过来的问题,而且也没什么异常,等我们发现一天时间已过去,问题已然升级为故障。合理的方式应该两个同步方案都使用,消息方式用于实时更新,HTTP 主动同步方式定时触发(比如 1 小时)用于兜底,即使消息出了问题,通过主动同步也能保证一小时一更新。

2.2 过载保护(保护自己)

如果是高并发场景使用的接口,那么需要做过载保护,防止服务过载引发雪崩。

相信很多做过高并发服务的同学都碰到类似事件:某天 A 君突然发现自己的接口请求量突然涨到之前的10倍,没多久该接口几乎不可使用,并引发连锁反应导致整个系统崩溃。

如何应对这种情况?生活给了我们答案:比如老式电闸都安装了保险丝,一旦有人使用超大功率的设备,保险丝就会烧断以保护各个电器不被强电流给烧坏。同理我们的接口也需要安装上“保险丝”,以防止非预期的请求对系统压力过大而引起的系统瘫痪,当流量过大时,可以采取拒绝或者引流等机制。

比如把接收到的请求放在指定的队列中排队处理,如果请求等待时间超时了(假设是 100ms),这个时候直接拒绝超时请求;再比如队列满了之后,就清除队列中一定数量的排队请求,保护服务不过载,保障服务高可用。

2.3 流量控制(保护下游)

流量控制,或者叫限流,一般用户保护下游不被大流量压垮。

常见的场景有:
(1)下游有严格的请求限制;比如银行转账接口,微信支付接口等都有严格的接口限频;

(2)调用的下游不是为高并发场景设计;比如提供异步计算结果拉取的服务,并不需要考虑各种复杂的高并发业务场景,提供高并发流量场景的支持。每个业务场景应该在拉取数据时缓存下来,而不是每次业务请求都过来拉取,将业务流量压垮下游。

(3)失败重试。调用下游失败了,一定要重试吗?如果不管三七二十一直接重试,这样是不对的,比如有些业务返回的异常表示业务逻辑出错,那么你怎么重试结果都是异常;又如有些异常是接口处理超时异常,这个时候就需要结合业务来判断了,有些时候重试往往会给后方服务造成更大压力,造成雪上加霜的效果。所有失败重试要有收敛策略,必要时才重试,做好限流处理。

控制流量,常用的限流算法有漏桶算法和令牌桶算法。必要的情况下,需要实现分布式限流。

2.4 快速失败

遵循快速失败原则,一定要设置超时时间。

某服务调用的一个第三方接口正常响应时间是 50ms,某天该第三方接口出现问题,大约有15%的请求响应时间超过2s,没过多久服务 load 飙高到 10倍以上,响应时间也非常缓慢,即第三方服务将我们服务拖垮了。

为什么会被拖垮?没设置超时!我们采用的是同步调用方式,使用了一个线程池,该线程池里最大线程数设置了50,如果所有线程都在忙,多余的请求就放置在队列里中。如果第三方接口响应时间都是 50ms 左右,那么线程都能很快处理完自己手中的活,并接着处理下一个请求,但是不幸的是如果有一定比例的第三方接口响应时间为 2s,那么最后这 50 个线程都将被拖住,队列将会堆积大量的请求,从而导致整体服务能力极大下降。

正确的做法是和第三方商量确定个较短的超时时间比如 200ms,这样即使他们服务出现问题也不会对我们服务产生很大影响。

2.5 无状态服务

尽可能地使微服务无状态。

无状态服务,可以横向扩展,从而不会成为性能瓶颈。

状态即数据。如果某一调用方的请求一定要落到某一后台节点,使用服务在本地缓存的数据(状态),那么这个服务就是有状态的服务。

我们以前在本地内存中建立的数据缓存、Session 缓存,到现在的微服务架构中就应该把这些数据迁移到分布式缓存中存储,让业务服务变成一个无状态的计算节点。迁移后,就可以做到按需动态伸缩,微服务应用在运行时动态增删节点,就不再需要考虑缓存数据如何同步的问题。

2.6 最少依赖

能不依赖的,尽可能不依赖,越少越好。

减少依赖,便可以减少故障发生的可能性,提高服务可靠性。

任何依赖都有可能发生故障,即使其如何保证,我们在设计上应尽可能地减少对第三方的依赖。如果无法避免,则需要对第三方依赖在发生故障时做好相应处理,避免因第三方依赖的抖动或不可用导致我们自身服务不可用,比如降级兜底。

2.7 简单可靠

可靠性只有靠不断追求最大程度的简化而得到。

乏味是一种美德。与生活中的其他东西不同,对于软件而言,“乏味”实际上是非常正面的态度。我们不想要自发性的和有趣的程序;我们希望这些程序按设计执行,可以预见性地完成目标。与侦探小说不同,缺少刺激、悬念和困惑是源代码的理想特征。

因为工程师也是人,他们经常对于自己编写的代码形成一种情感依附,这些冲突在大规模清理源代码的时候并不少见。一些人可能会提出抗议,“如果我们以后需要这个代码怎么办?”,“我们为什么不只是把这些代码注释掉,这样稍后再使用它的时候会更容易。”,“为什么不增加一个功能开关?”,这些都是糟糕的建议。源代码控制系统中的更改反转很容易,数百行的注释代码则会造成干扰和混乱;那些由于功能开关没有启用而没有被执行的代码,就像一个定时炸弹等待爆炸。极端地说,当你指望一个Web服务7*24可以用时,某种程度上,每一行新代码都是负担。

法国诗人 Antoine de Saint-Exupéry 曾写道:“不是在不能添加更多的时候,而是没有什么可以去掉的时候,才能达到完美”。这个原则同样适用于软件设计。API 设计是这个规则应该被遵循的一个清晰的例子。书写一个明确的、简单的 API 是接口可靠的保证。我们向 API 消费者提供的方法和参数越少,这些 API 就越容易理解。在软件工程上,少就是多!一个很小的,很简单的 API 通常也是一个对问题深刻理解的标志。

软件的简单性是可靠性的前提条件。当我们考虑如何简化一个给定的任务的每一步时,我们并不是在偷懒。相反,我们是在明确实际上要完成的任务是什么,以及如何容易地做到。我们对新功能说“不”的时候,不是在限制创新,而是在保持环境整洁,以免分心。这样我们可以持续关注创新,并且可以进行真正的工程工作。

2.8 分散原则

鸡蛋不要放一个篮子,分散风险。

比如一个模块的所有接口不应该放到同一个服务中,如果服务不可用,那么该模块的所有接口都不可用了。我们可以基于主次进行服务拆分,将重要接口放到一个服务中,次要接口放到另外一个服务中,避免相互影响。

再如所有交易数据都放在同一个库同一张表里面,万一这个库挂了,此时影响所有交易。我们可以对数据库水平切分,分库分表。

2.9 隔离原则

控制风险不扩散,不放大。

不同模块之间要相互隔离,避免单个模块有问题影响其他模块,传播扩散了影响范围。

比如部署隔离:每个模块的服务部署在不同物理机上;

再如 DB 隔离:每个模块单独使用自身的存储实例。

古代赤壁之战就是一个典型的反面例子,铁锁连船导致隔离性被破坏,一把大火烧了80W大军。

隔离是有级别的,隔离级别越高,风险传播扩散的难度就越大,容灾能力越强。

例如:一个应用集群由N台服务器组成,部署在同一台物理机上,或同一个机房的不同物理机上,或同一个城市的不同机房里,或不同城市里,不同的部署代表不同的容灾能力。

例如:人类由无数人组成,生活在同一个地球的不同洲上,这意味着人类不具备星球级别的隔离能力,当地球出现毁灭性影响时,人类是不具备容灾的。

2.10 幂等设计(可重入)

所谓幂等,简单地说,就是对接口的多次调用所产生的结果和调用一次是一致的。数据发生改变才需要做幂等,有些接口是天然保证幂等性的。

比如查询接口,有些对数据的修改是一个常量,并且无其他记录和操作,那也可以说是具有幂等性的。其他情况下,所有涉及对数据的修改、状态的变更就都有必要防止重复性操作的发生。实现接口的幂等性可防止重复操作所带来的影响。

重复请求很容易发生,比如用户误触,超时重试等。举个最简单的例子,那就是支付,用户购买商品后支付,支付扣款成功,但是返回结果时网络异常(超时成功),此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条,就没有保证接口的幂等性。

2.11 故障自愈

没有 100% 可靠的系统,故障不可避免,但要有自愈能力。

人体拥有强大的自愈能力,比如手指划破流血,会自动止血,结痂,再到皮肤再生。微服务应该像人体一样,当面对非毁灭性伤害(故障)时,在不借助外力的情况下,自行修复故障。比如消息处理或异步逻辑等非关键操作失败引发的数据不一致,需要有最终一致的修复操作,如兜底的定时任务,失败重试队列,或由用户在下次请求时触发修复逻辑。

2.12 CAP 原则

CAP 原则又称 CAP 定理。

1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标。

一致性(Consistency)
可用性(Availability)
分区容错性(Partition tolerance)

它们的第一个字母分别是 C、A、P。

Eric Brewer 说,这三个指标最多只能同时实现两点,不可能三者兼顾。这个结论就叫做 CAP 定理。

CAP 原则告诉我们,如果服务是分布式服务,那么不同节点间通信必然存在失败可能性,即我们必须接受分区容错性(P),那么我们必须在一致性(C)和可用性(A)之间做出取舍,即要么 CP,要么 AP。

如果你的服务偏业务逻辑,对接用户,那么可用性显得更加重要,应该选择 AP,遵守 BASE 理论,这是大部分业务服务的选择。

如果你的服务偏系统控制,对接服务,那么一致性显得更加重要,应该选择 AP,遵守 ACID 理论,经典的比如 Zookeeper。

总体来说 BASE 理论面向的是大型高可用、可扩展的分布式系统。与传统 ACID 特性相反,不同于ACID的强一致性模型,BASE 提出通过牺牲强一致性来获得可用性,并允许数据段时间内的不一致,但是最终达到一致状态。同时,在实际分布式场景中,不同业务对数据的一致性要求不一样,因此在设计中,ACID 和BASE 应做好权衡和选择。

3.高性能

3.1 无锁

3.1.1 锁的问题

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

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

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

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

3.1.2 串行无锁

串行无锁最简单的实现方式可能就是单线程模型了,如 Redis/Nginx 都采用了这种方式。在网络编程模型中,常规的方式是主线程负责处理 I/O 事件,并将读到的数据压入队列,工作线程则从队列中取出数据进行处理,这种单 Reactor 多线程模型需要对队列进行加锁,这种模型叫单 Reactor 多线程模型。如下图所示:

上图的模式可以改成串行无锁的形式,当 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 优秀的本地缓存组件 bigcache 、go-cache、freecache 都实现了分片功能,每个分片一把锁,采用分片存储的方式减少加锁的次数从而提高整体性能。

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

var (num = 1000000m0  = 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/opfunc BenchmarkConWriteMapTwoShard(b *testing.B) {for i := 0; i < b.N; i++ {ConWriteMapTwoShard()}
}
BenchmarkConWriteMapTwoShard-12                4         310588155 ns/op

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

3.2 缓存

3.2.1 为什么要有缓存?

数据的访问具有局部性,符合二八定律:80% 的数据访问是集中在 20% 的数据上,这部分数据也被称作热点数据。

不同层级的存储访问速率不同,内存读写速度快于磁盘,磁盘快于远端存储。基于内存的存储系统(如 Redis)高于基于磁盘的存储系统(如 MySQL)。

因为存在热点数据和存储访问速率的不同,我们可以考虑采用缓存。

缓存缓存一般使用内存作为本地缓存。

必要情况下,可以考虑多级缓存,如一级缓存采用本地缓存,二级缓存采用基于内存的存储系统(如 Redis、Memcache 等)。

缓存是原始数据的一个复制集,其本质就是空间换时间,主要是为了解决高并发读。

3.2.2 缓存的使用场景

缓存是空间换时间的艺术,使用缓存能提高系统的性能。“劲酒虽好,可不要贪杯”,使用缓存的目的是为了提高性价比,而不是一上来就为了所谓的提高性能不计成本的使用缓存,而是要看场景。

适合使用缓存的场景,以之前参与过的项目企鹅电竞为例:
(1)一旦生成后基本不会变化的数据:如企鹅电竞的游戏列表,在后台创建一个游戏之后基本很少变化,可直接缓存整个游戏列表;

(2)读密集型或存在热点的数据:典型的就是各种 App 的首页,如企鹅电竞首页直播列表;

(3)计算代价大的数据:如企鹅电竞的 Top 热榜视频,如 7 天榜在每天凌晨根据各种指标计算好之后缓存排序列表;

(4)千人一面的数据:同样是企鹅电竞的 Top 热榜视频,除了缓存的整个排序列表,同时直接在进程内按页缓存了前 N 页数据组装后的最终回包结果;

不适合使用缓存的场景:

(1)写多读少,更新频繁;

(2)对数据一致性要求严格。

3.2.3 缓存的分类?

(1)进程级缓存

缓存的数据直接在进程地址空间内,这可能是访问速度最快使用最简单的缓存方式了。主要缺点是受制于进程空间大小,能缓存的数据量有限,进程重启缓存数据会丢失。一般通常用于缓存数据量不大的场景。

(2)集中式缓存

缓存的数据集中在一台机器上,如共享内存。这类缓存容量主要受制于机器内存大小,而且进程重启后数据不丢失。常用的集中式缓存中间件有单机版redis、memcache等。

(3)分布式缓存

缓存的数据分布在多台机器上,通常需要采用特定算法(如 Hash)进行数据分片,将海量的缓存数据均匀的分布在每个机器节点上。常用的组件有:Memcache(客户端分片)、Codis(代理分片)、Redis Cluster(集群分片)。

(4)多级缓存

指在系统中的不同层级进行数据缓存,以提高访问效率和减少对后端存储系统的冲击。

3.2.4 缓存的使用模式

关于缓存的使用,已经有人总结出了一些模式,主要分为 Cache-Aside 和 Cache-As-SoR 两类。其中 SoR(System-of-Record)表示记录系统,即数据源,而 Cache 正是 SoR 的拷贝。

  • Cache-Aside:旁路缓存

这应该是最常见的缓存模式了。对于读,首先从缓存读取数据,如果没有命中则回源 SoR 读取并更新缓存。对于写操作,先写 SoR,再写缓存。这种模式架构图如下:

这种模式用起来简单,但对应用层不透明,需要业务代码完成读写逻辑。同时对于写来说,写数据源和写缓存不是一个原子操作,可能出现以下情况导致两者数据不一致。

(1)在并发写时,可能出现数据不一致。

如下图所示,user1 和 user2 几乎同时进行读写。在 t1 时刻 user1 写 db,t2 时刻 user2 写 db,紧接着在 t3 时刻 user2 写缓存,t4 时刻 user1 写缓存。这种情况导致 db 是 user2 的数据,缓存是 user1 的数据,两者不一致。

(2)先写数据源成功,但是接着写缓存失败,两者数据不一致。

对于这两种情况如果业务不能忍受,可简单的通过先delete缓存然后再写db解决,其代价就是下一次读请求的 cache miss。

  • Cache-as-SoR:缓存即数据源

该模式把 Cache 当作 SoR,所以读写操作都是针对 Cache,然后 Cache 再将读写操作委托给 SoR,即 Cache 是一个代理。如下图所示:

有三种实现方式:

(1)Read-Through:称为穿透读模式,首先查询 Cache,如果不命中则再由 Cache 回源到 SoR 即存储端实现 Cache-Aside 而不是业务)。

(2)Write-Through:称为穿透写模式,由业务先调用写操作,然后由 Cache 负责写缓存和 SoR。

(3)Write-Behind:称为回写模式,发生写操作时业务只更新缓存并立即返回,然后异步写 SoR,这样可以利用合并写/批量写提高性能。

3.2.5 缓存淘汰策略

在空间有限、低频热点访问或者无主动更新通知的情况下,需要对缓存数据进行回收,常用的回收策略有以下几种:

(1)基于时间:基于时间的策略主要可以分两种。

  • TTL(Time To Live):即存活期,从缓存数据创建开始到指定的过期时间段,不管有没有访问缓存都会过期。如 Redis 的 EXPIRE。
  • TTI(Time To Idle):即空闲期,缓存在指定的时间没有被访问将会被回收。

(2)基于空间:缓存设置了存储空间上限,当达到上限时按照一定的策略移除数据。

(3)基于容量:缓存设置了存储条目上限,当达到上限时按照一定的策略移除数据。

(4)基于引用:基于引用计数或者强弱引用的一些策略进行回收。

缓存常见淘汰算法如下:

  • FIFO(First In First Out):先进选出原则,先进入缓存的数据先被移除。
  • LRU(Least Recently Used):最基于局部性原理,即如果数据最近被使用,那么它在未来也极有可能被使用,反之,如果数据很久未使用,那么未来被使用的概率也较。
  • LFU:(Least Frequently Used):最近最少被使用的数据最先被淘汰,即统计每个对象的使用次数,当需要淘汰时,选择被使用次数最少的淘汰。

3.2.6 缓存的崩溃与修复

由于在设计不足、请求攻击(并不一定是恶意攻击)等会造成一些缓存问题,下面列出了常见的缓存问题和解决方案。

  • 缓存穿透

大量使用不存在的 Key 进行查询时,缓存没有命中,这些请求都穿透到后端的存储,最终导致后端存储压力过大甚至被压垮。这种情况原因一般是存储中数据不存在,主要有三个解决办法。

(1)设置空置或默认值:如果存储中没有数据,则设置一个空置或者默认值缓存起来,这样下次请求时就不会穿透到后端存储。但这种情况如果遇到恶意攻击,不断的伪造不同的 Key 来查询时并不能很好的应对,这时候需要引入一些安全策略对请求进行过滤。

(2)布隆过滤器:采用布隆过滤器将,将所有可能存在的数据哈希到一个足够大的 Bitmap 中,一个一定不存在的数据会被这个 Bitmap 拦截掉,从而避免了对底层数据库的查询压力。

(3)singleflight
多个并发请求对一个失效的 Key 进行源数据获取时,只让其中一个得到执行,其余阻塞等待到执行的那个请求完成后,将结果传递给阻塞的其他请求达到防止击穿的效果。

  • 缓存雪崩

指大量的缓存在某一段时间内集体失效,导致后端存储负载瞬间升高甚至被压垮。通常是以下原因造成:

(1)缓存失效时间集中在某段时间,对于这种情况可以采取对不同的 Key 使用不同的过期时间,在原来基础失效时间的基础上再加上不同的随机时间;

(2)采用取模机制的某缓存实例宕机,这种情况移除故障实例后会导致大量的缓存不命中。有两种解决方案:
(a)采取主从备份,主节点故障时直接将从实例替换主;
(b)使用一致性哈希替代取模,这样即使有实例崩溃也只是少部分缓存不命中。

  • 缓存热点

虽然缓存系统本身性能很高,但也架不住某些热点数据的高并发访问从而造成缓存服务本身过载。假设一下微博以用户 ID 作为哈希 Key,突然有一天亦菲姐姐宣布婚了,如果她的微博内容按照用户 ID 缓存在某个节点上,当她的万千粉丝查看她的微博时必然会压垮这个缓存节点,因为这个 Key 太热了。这种情况可以通过生成多份缓存到不同节点上,每份缓存的内容一样,减轻单个节点访问的压力。

3.2.7 缓存的一些好实践

  • 动静分离

对于一个缓存对象,可能分为很多种属性,这些属性中有的是静态的,有的是动态的。在缓存的时候最好采用动静分离的方式。以免因经常变动的数据发生更新而要把经常不变的数据也更新至缓存,成本很高。

  • 慎用大对象

如果缓存对象过大,每次读写开销非常大并且可能会卡住其他请求,特别是在redis这种单线程的架构中。典型的情况是将一堆列表挂在某个 value 的字段上或者存储一个没有边界的列表,这种情况下需要重新设计数据结构或者分割 value 再由客户端聚合。

  • 过期设置

尽量设置过期时间减少脏数据和存储占用,但要注意过期时间不能集中在某个时间段。

  • 超时设置

缓存作为加速数据访问的手段,通常需要设置超时时间而且超时时间不能过长(如100ms左右),否则会导致整个请求超时连回源访问的机会都没有。

  • 缓存隔离

首先,不同的业务使用不同的 Key,防止出现冲突或者互相覆盖。其次,核心和非核心业务进行通过不同的缓存实例进行物理上的隔离。

  • 失败降级

使用缓存需要有一定的降级预案,缓存通常不是关键逻辑,特别是对于核心服务,如果缓存部分失效或者失败,应该继续回源处理,不应该直接中断返回。

  • 容量控制

使用缓存要进行容量控制,特别是本地缓存,缓存数量太多内存紧张时会频繁的swap存储空间或GC操作,从而降低响应速度。

  • 业务导向

以业务为导向,不要为了缓存而缓存。对性能要求不高或请求量不大,分布式缓存甚至数据库都足以应对时,就不需要增加本地缓存,否则可能因为引入数据节点复制和幂等处理逻辑反而得不偿失。

  • 监控告警

对大对象、慢查询、内存占用等进行监控,做到缓存可观测,用得放心。

3.3 异步

3.3.1 调用异步

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

  • Callback

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

  • Future

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

  • CPS(Continuation-passing style)

可以对多个异步编程进行编排,组成更复杂的异步处理,并以同步的代码调用形式实现异步效果。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 零拷贝

零拷贝就是一种避免 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 也使用零拷贝技术。

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 模式,既避免了书写不同参数版本的函数,又达到了无限扩增函数参数的效果,增加了函数扩展性。

5.小结

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

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

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


参考文献

Google Cloud API Desgin Guide
知乎.怎么理解软件设计中的开闭原则?
微服务的4个设计原则和19个解决方案
博客园.如何健壮你的后端服务?
高可用的本质
一文搞懂后台高性能服务器设计的常见套路, BAT 高频面试系列
【架构】高可用高并发系统设计原则
CAP 定理的含义 - 阮一峰的网络日志
CAP理论该怎么理解?为什么是三选二?为什么是CP或者AP?面试题有哪些?
Cache Usage Patterns - Ehcache

微服务(接口)设计原则相关推荐

  1. 腾讯技术分享:微服务接口设计原则

    来源|腾讯技术工程(ID:Tencent_TEG) 本文结合自身后台开发经验,从高可用.高性能.易维护和低风险(安全)角度出发,尝试总结业界常见微服务接口设计原则,帮助大家设计出优秀的微服务. 1.前 ...

  2. 微服务架构 — 设计原则

    目录 文章目录 目录 单一责任原则 独立数据存储原则 使用异步通信实现松散耦合 通过 APIGW 代理微服务请求 确保 API 变更向后兼容 版本化微服务的重大变更 使用熔断器快速实现故障容错 使用专 ...

  3. 微服务-数据库设计原则

    数据和应用分离:①数据库位置透明,应用系统只依赖于逻辑数据库:②不直接访问其他宿主的数据库,只能通过服务访问 数据库主备从:①数据库必须配置备库:②不同业务域用不同的Schema隔离 不过度依赖缓存: ...

  4. 谈一下我对如何设计微服务接口的理解和思考

    微服务是一个独立运行.自带数据存储管理,对外提供接口的自治系统.微服务设计很关键的一点是微服务接口的设计.不同微服务经常是分配给不同的团队开发的,接口是各团队编程的契约. 下面只讨论微服务间接口的设计 ...

  5. 微服务架构设计总结实践

    -     目录    - 一.微服务架构介绍 二.出现和发展 三.传统开发模式和微服务的区别 四.微服务的具体特征 五.SOA和微服务的区别 六.如何具体实践微服务 七.常见的微服务设计模式和应用 ...

  6. 微服务架构设计总结实践篇,10 步搭建微服务

    一.微服务架构介绍 二.出现和发展 三.传统开发模式和微服务的区别 四.微服务的具体特征 五.SOA和微服务的区别 六.如何具体实践微服务 七.常见的微服务设计模式和应用 八.微服务的优点和缺点 九. ...

  7. 微服务架构-设计总结,看了都说好!

    点击上方 "编程技术圈"关注, 星标或置顶一起成长 后台回复"大礼包"有惊喜礼包! 每日英文 Sometimes, the same thing, we can ...

  8. 微服务接口限流的设计与思考(附GitHub框架源码)

    http://www.infoq.com/cn/articles/microservice-interface-rate-limit?useSponsorshipSuggestions=true&am ...

  9. 你必须了解的微服务架构设计的10个要点!

    近来,几乎人人都在谈论微服务.微服务之所以火热也是因为相对之前的应用开发方式有很多优点,如更灵活.更能适应现在需求快速变更的大环境等.本文将介绍微服务架构设计中的一些要点. 微服务架构设计时有哪些要点 ...

  10. Java生鲜电商平台-秒杀系统微服务架构设计与源码解析实战

    Java生鲜电商平台-秒杀系统微服务架构设计与源码解析实战 Java生鲜电商平台-  什么是秒杀 通俗一点讲就是网络商家为促销等目的组织的网上限时抢购活动 比如说京东秒杀,就是一种定时定量秒杀,在规定 ...

最新文章

  1. simulink中mask设置_Mask Editor 概述
  2. 远程linux的几个工具
  3. 复选框全选效果,根据单个复选框的选择情况确定全选复选框是否被选
  4. Object C学习笔记19-枚举(转)
  5. spark任务jvm内存溢出
  6. layui js添加html,layui.js如何声明全局变量?
  7. 用 Identity Server 4 (JWKS 端点和 RS256 算法) 来保护 Python web api
  8. js 自定义类Android吐司提示框
  9. 糖豆人服务器要维护多久,糖豆人刚上四天,紧急维护9小时后又延长,玩家:从中午到现在了...
  10. c语言中把时间拷到字符串中,C语言中将日期和时间以字符串格式输出的方法
  11. 同一个页面同时get post_get与post的区别
  12. 零基础自学Java要多久,是不是很难?
  13. marked转换html失败,解析markdown之marked
  14. Notion笔记软件简介
  15. Netcdf4.4的安装过程(附netcdf4.1.3的安装过程)
  16. conda 安装第三方包
  17. 计划任务(啊啊啊啊啊)
  18. 锂电池过充电、过放电、短路保护电路详解
  19. spring---自定义Filter有两种方式
  20. 什么是版权,怎么申请

热门文章

  1. 只需一条信息即可远程利用严重的思科 Jabber RCE缺陷
  2. django获取字段列表(values/values_list/flat)
  3. 计算机中的进制位运算
  4. 14.12.1类的特殊成员1
  5. 《深入理解Elasticsearch(原书第2版)》一第1章
  6. centos下 安装jdk
  7. 数据可视化(5)--jqplot经典实例
  8. 当有多个设备online时,命令行窗口通过adb连接指定设备方法
  9. 蓝桥杯 ADV-145 算法提高 铺地毯
  10. 蓝桥杯 ADV-237 算法提高 三进制数位和