Git 也谈如何构建高性能服务端程序

coding · November 18, 2014 · Last by lonely21475 replied at January 27, 2015 · 9824 hits
Topic has been selected as the excellent topic by the admin.

也谈如何构建高性能服务端程序

引子:我接触过很多编程语言,接触过各种各样的服务器端开发,Java,Go,Ruby,Javascript 等语言,Spring,Node.js,Rails 等等常见服务器端框架和编程模型都有接触。这里谈一下我个人对高性能服务器端程序的一些看法,希望给各位读者一些认识。这片文章提到的内容也是 Coding(https://coding.net)代码托管乃至整站都在使用的一些概念和技术。

此外,阅读这篇文章,有如下几个前提:不谈硬件,不评论编程语言以及框架的好坏,不谈高级算法,可拍砖,拒绝喷子

三个关键词

Cache,Asynchronous,Concurrent 我们一个一个来讲。

Cache

Cache 翻译成中文就是缓存,台湾的叫法叫做快取,其本质是将获取缓慢或者计算缓慢的数据结果暂时存储起来,以便以后再次获取或者计算同样的数据可以直接从存储中取得结果,从而可能提升性能的一种手段。Cache 最早是应用在计算机的 CPU 中,这篇文章不谈硬件,所以有需要了解 CPU 的缓存的同学可自行搜索。

可以想象,如果让一个人一遍一遍的从 1+2+3+4+...+99+100=?这样去算,他加到最后发现等于 5050,而这个过程耗费了他大量的时间,耗费了大量的脑力,在此期间,他可能把所有精力都放在这个计算上面而无暇顾及其他事情。等到他累得满头大汗,加完了结果,他告诉你是 5050。没过多久,你又让他做同样的事情,我相信这家伙会不加思索的再次告诉你 5050。为什么?你会笑我说,人又不是傻子,这为同学肯定记得这个结果是 5050 啊。

可是,计算机不一样,计算机就是你上面要嘲笑的那个傻子,他傻到,完全不会记得刚在做了什么事情,他会傻乎乎的再重新算一遍告诉你结果。没错如果你问他一万遍,这头没有脑子的机器会算一万遍的。虽然上面这个从 1 加到 100 这个例子对于一款现代化的计算机来讲简直是小菜一碟,但是计算机往往面临的计算难题是我们人类所无法企及的。

Cache 就是为了来解决这个事情的,因为事情往往是这样的:你会发现一些非常复杂的过程的计算结果是可重用的,而且把这个结果暂时存储在某些地方,查找起来也是极为方便的。

所以,现在你理解了缓存,那可以来思考一些缓存的设计策略了。这里做一点说明,不同的缓存策略跟具体的业务系统关系非常大,制定缓存策略需要根据具体的情况来分析。常用的策略:

  • 最终结果型缓存。这种缓存往往提升性能效果最为明显,但是命中率却低,也就是可重用性不高。
  • 中间结果型缓存。还拿上面的例子来说,1 加到 100,你可以构建出是个缓存分别是 1 加到 10,10 加到 20,20 加到 30 ... 一直到 90 加到 100 这 9 个缓存。好处是你如果被请求到 1 加到 60 的时候,仍然可以使用这些缓存结果。可坏处也很明显,你取到几个缓存的结果后不得不再进行一次运算。所以实际情况,往往是在最终结果和中间结果之间找到平衡点,或者是两者配合使用。

不知不觉中,你有没有发现,1+2+3+4+...+99+100=5050 是个永远都成立的事实,这也就意味着,它永远不用被清除。可事实是往往是,缓存是有有效期的,例如需要缓存今天的天气情况,今天是 2014 年 11 月 16 日,到了明天就是 11 月 17 日,天气就不一样了。再例如需要缓存 Coding 的最新冒泡列表,当有人发布了新的冒泡,那么这个列表就得被更新。从这个角度来看,缓存的策略又有如下常见的几种:

  • 永久式缓存:结果在任何情况下都不发生改变,无需清除或者更新
  • 有有效期的缓存:在特定时间点或者时间段后失效
  • 触发式失效缓存:当某一事件产生时,缓存失效,当然有有效期式缓存也可以理解成时间点和时间段到期为触发条件的触发式失效缓存

嗯,既然提到了缓存的更新或者清除,那么就牵扯到缓存的更新策略。例子永远好过大段的理论:假如我们要缓存 Coding 的冒泡列表。有这么一种策略:当用户请求时我们检查下是否已存在这样的缓存,如果有直接返回缓存数据,否则我们生成这个列表(计算机的计算过程),返回给用户并且把冒泡列表(计算结果)存储起来,以便以后的用户访问时直接获取。当用户发布了一个新的冒泡的时候,我们清除这个缓存,再有用户请求时将重复以上过程。这是其中一种完整的缓存清除策略。另外一种是,每当我们收到一个用户发布的冒泡时,都重新构建这个缓存,用户每次查看冒泡列表都是取的缓存数据。这两种缓存分别称之为:

  • 被动式缓存:需要用到时才构建
  • 主动式缓存:预先构建

关于 Cache 还有很多很多需要注意和设计上的思路和策略,这里不再一一赘述。这些缓存在不同的维度有不同的策略,我们需要根据具体的业务情况来选择合适的策略。Coding 的很多业务中使用了上述很多种策略,例如我们常见的分支列表和标签列表就是使用触发式失效缓存,我们的广场项目列表就是使用主动式缓存构建。

Asynchronous

Asynchronous 的意思是异步。什么是异步呢?就是不在第一时间告知调用者结果,告诉他我已经收到这个任务了,我会处理,处理完毕后通知你结果,如果你不是等不到结果就无法进行下去的话,你完全可以先干别的事情。 嗯,好像我描述的比较拉杂。还是例子:你去咖啡厅点一杯咖啡,服务员告诉你现磨咖啡需要 15 分钟才可做好,那么在咖啡做好之前,你不可能盯着服务员或者咖啡师 15 分钟,你肯定会干点别的,比如说玩手机上一下网,或者跟你女朋友商量下去看电影什么的,总之你不会傻乎乎等着的。等到咖啡做好了,服务员会记得给你端过来的。这就是异步过程,你的大脑不必为一个漫长的过程卡住,可以继续其他的事情。

服务端程序设计往往也是这样,在你等待一个很缓慢的过程的时候,如果你不是必须要得到这个过程的结果才能继续下去,你完全可以先进行别的过程,等到那个缓慢的过程执行完毕后,它会通知你结果的。

异步已经在现在的各种编程领域有了很广泛的应用,例如 Ajax 技术,就是一种异步的手段,在浏览器和服务器交互的时候,完全不影响你在网页上的其他操作。

异步在各种编程语言和框架中都有相应的支持,这里简单介绍一下 Javascript 的异步支持。熟悉它的人的人请无视这段。它使用回调的方式支持异步,大致意思是,A 交代给 B 一个任务,并且告知 B 任务完成后继续执行哪段程序(往往包装成一个匿名 function),B 执行完任务后,执行这个匿名的 function,这样来完成异步过程。在 Javascript 中大量的使用这种回调的异步方案,已经不再局限于对一个缓慢的过程了,可以对几乎所有的过程都采用异步处理。

在服务端程序中,除了使用线程,协程,回调之外,另外一种常见的异步的支持方式就是消息队列。其原理是,生产者发送消息到消息队列中,消费者从中取出消息,做出相应处理,并把结果存储起来或者通过某种方式告知生产者。

异步在很多时候可以运用现代化计算机 CPU 的多核特性和分布式计算特性,能显著的提升应用的性能,但是一个前提就是,异步的任务的结果必须是主进程进行下一步操作所不依赖的,否则主进程必须等待,直到这个任务执行结束,拿到结果再进行下一步,这时就变成了传统的同步计算了。

异步操作在 Coding 中也有非常广泛的应用。例如当用户执行完一次 Push,Coding 需要生成一条 Push 的动态,需要清理掉相应的缓存,需要触发相关的 WebHook 等等,这些操作都是通过消息队列来异步完成的。因为这些操作非常的耗时,而且完全不需要即时完成,所以用户在 Push 的时候等待着这些操作完成是很不合理的。异步操作在这里即展示出了其应用多核和多台服务器的优势,在某种程度上还能提升用户体验。

Golang 是 Google 2009 年发布的一门现代化语言,其语言特性对异步提供了良好的支持。这里举个例子体现一下异步的魅力:

//一个查询结构体
type project struct {
    //参数Channel
    name chan string
    result chan string
}

//addProject
func addProject(u user, p project) {

    //检查用户权限
    checkPermission(u)

    //启动协程
    go func() {
        //获取输入
        name := <-p.name
        //访问数据库,输出结果通道
        q.result <- "add project :" + name
    }()

}

//主进程
func main() {
    //初始化project
    p := project{ make(chan string, 1), make(chan string, 1) }
    //某位用户
    u := user{} 
    //执行addProject,注意执行的时候还不需要告知要创建的项目名字
    addProject(u,p)

    //准备参数
    p.name <- "an-asynchronous-project"
    //获取结果
    fmt.Println(<-p.result)
}

这一段程序涉及到了 Golang 的 goroutine 和 channel,不了解的可以去查一下相关资料。 这段程序实现了在还为准备好参数时就已经调用一个 function。当我们调用 addProject 的时候还不知道项目的名字,但是这完全不影响我们去检查用户权限。程序完全可以一边去检查权限,一边去获取项目名字,当程序执行到不得不拿到项目的名字才能继续的时候,它将阻塞,直到我们告诉他项目名字。

Concurrent

Concurrent 的意思是并行。现代化的 CPU 往往具有多个核心,而且有些 CPU 也具有超线程能力。如果我们可以将单个过程拆分成小的任务,交给 CPU 的多个核心,或者是分布式计算系统的多个计算节点,就可以充分利用并行计算来提升性能。前提是这些任务相互之间不要有相互依赖的关系。依然是例子:需要计算网站上某一批用户的活跃度积分,传统的,我们会查出这一批用户,然后写一个循环,然后轮流计算他们的积分,最后得到结果。其实每个用户的积分的计算都是独立的,相互不依赖,那么我们就可以利用这一点来并行化这个计算。

下面给出一段 Coding 代码托管中的程序,这段程序是指定条件获取一个提交列表,使用了并行计算的一种 并发循环


public List<Commit> getCommits(String objectId, String path, int offset, int maxCount) {
        List<String> shas = getCommitsSha(this, objectId, path, offset, maxCount);
        List<Commit> commits = new ArrayList<>();

        if (shas != null) {
            List<GetCommit> getCommits = new ArrayList<>();
            for (String sha : shas) {
                getCommits.add(new GetCommit(this, sha));
            }

            //声明一个自适应的线程池
            ExecutorService executor = Executors.newFixedThreadPool(8);

            List<Future<Commit>> futureList = null;

            //并发的调用getCommit
            futureList = executor.invokeAll(getCommits);
            executor.shutdown();

            for (Future<Commit> future : futureList) {
                Commit commit = future.get();
                commits.add(commit);
            }        
        }
        return commits;
    }

    //Java 是一个啰嗦的语言,还要声明一个类来包装一下这个过程。
    class GetCommit implements Callable<Commit> {
        private Repo repo;
        private String sha;

        public GetCommit(Repo repo, String sha) {
            this.repo = repo;
            this.sha = sha;
        }

        @Override
        public Commit call() throws Exception {
            return repo.getCommit(sha);
        }
    }

这段程序是一个并发循环的例子,例子中需要根据一些参数查询到 Commit 的列表,而 repo.getCommit 这个过程完全不需要一个一个轮流查询,因为他们是完全独立的,所以可以使用 Java 的 Cocurrent 包来做并发循环,充分利用多核来尽快得到执行结果。

总结

关于高性能服务器程序需要关注的点还有很多,这里只是简单的介绍了下三个利器(Cache,Asynchronous,Concurrent)。而即便是这三个利器,我的介绍也只是冰山一角,但是请相信你看懂了我介绍的这些东西,重新去思考服务端编程会获得不少收获的。 这三者也是相辅相成的关系,很多时候都是配合着使用才能起到很好的效果。异步和并行在某种程度上是有重叠的,而我们经常使用异步的方式去主动构建缓存。

最后再给一些小提示:

  • 不要让 CPU 闲着(CPU 正常情况下压力大的时候自然不会闲着,这里指的是 CPU 负载低谷时,可以让他主动的构建缓存,或者做一些准备工作等等。)
  • 提升 CPU 效率,即不要总让 CPU 做重复的劳动,用空间换时间的理念去减轻 CPU 的压力
  • 不要让无关紧要的附属的任务卡住主进程,让他们在后台慢慢做
  • 可以提前做好准备工作,这个比较抽象,但是举例子就很明白,连接池,主动缓存,以及我举得那个 Golang 的例子都是很好的例子

作者:Coding 架构师 王振威 @jack230230 本文出自 Coding 官方技术博客:http://blog.coding.net/

如果只讲 pattern 个人感觉不必要这么多代码,影响理解

抱枕赞一个…哈哈

你好,有个问题想问一下,线程池启动 8 个这个『8』是如何确定下来的呢?

#3 楼 @ywjno 一般是 cpu 核数吧

受教了,最近也在关注这方面的知识。

Coding 官方技术博客的文章都很 hardcore 呀!

#4 楼 @i5ting 不是应该根据『CPU 可用核心数』算出『最佳线程数』来的么

Concurrent 的意思是并行

不是并发吗

#8 楼 @treasurelife parallel 是并行,concurrency 是并发吧。文章这个小标题应该有错。

网站页面很棒,给设计师一个赞

//Java 是一个啰嗦的语言,还要声明一个类来包装一下这个过程。
    class GetCommit implements Callable<Commit> {

哈哈,严重同意,楼主的代码还是很整洁的,但要是能再分分层,就更好了。

ThreadPoolExecutor 不是并发工具,而是 限制并发 的工具...

例如只起一个的话,网站有 1000 并发请求,但是调用到这个 ThreadPoolExcecutor 就卡成 8 并发了...

如果改成多线程部署环境每个请求起一个 ThreadPoolExecutor, 那线程数就爆炸了...

#3 楼 @ywjno 个人的做法一般是 cpu 核心数的两倍

#3 楼 @ywjno 妹纸我直接请出作者 @jack230230 来回答您啦~

#12 楼 @luikore 是多线程部署环境,如果你的单次请求本身就很慢,那还想支撑巨大的请求量,自然是要加强硬件,改进算法,这里的情况是在这两者确定的情况下,如何通过优先的 CPU 资源加速请求执行时间

@jack230230 是本文作者哦 ! 谢谢~

Asynchronous,Concurrent 实质都是一个东西, 就是对数据进行了分步处理

#16 楼 @jack230230

ThreadPoolExecutor 主要有两种应用场景:一是某种需求的资源支持不了高并发,要限制并发; 二是服务器本身面对的并发数不高,吃不满 CPU. 你可以 wrk 或者 ab 用高并发的情况测一下会不会爆炸...

至于开多少个线程,如果每个 worker 都是纯 CPU 计算并且 有足够的 CPU (例如网站请求量很低), 就开核心数目个,这时它是作为一个并行工具而不是并发工具。

如果 worker 是 IO 密集型的就得多开,开多少应该看实际情况而不是 "凭经验", 但开的太多 ThreadPool 就变成虚设了。

#19 楼 @luikore 我们这种情形是你说的第二种情况,基本上是大多数请求都可以很快执行完毕,少量请求需要耗时很多,利用多核可以在很大程度上提升执行速度,这个数字“8”只是为了告诉读者一个大概的意思,我们实际的代码是有根据具体情况判断的。

#18 楼 @coolesting 只能说两者有共通之处,说实质是一个东西还是不贴切的

不错,全是干活~

高性能还会受其他东西影响,比如架构设计的是否足够简洁并且可以平行扩展。。。

#23 楼 @zeeler 是的,所以这篇文章只是冰山一角

我最近开始喜欢 java 了,哈哈,你说的啰嗦只是语法层面,但是很多时候需要考虑的不仅仅是语法,何况 java 也可以用很少的代码写出很牛逼的程序来。

嗯,你的总结就是我刚开始看的时候想说的。但你还缺了一个非常重要的一个环节,性能日志和打点。没有这些数据,你就不知道程序慢在哪一个环节,也不知道是否每台机器都发挥了最大效能,灰度的时候就不能打回愚蠢的实现方案,也无法为性能提升提供参考。做服务端程序文中提到的三点就够了,做高性能服务端程序,这个第四点是必要。

#26 楼 @casatwy 高性能服务器开发能讲的太多了,但是这三个是最易用,效果最好的

酷~ 写的太棒了。

谢谢分享,请问 coding.net 是 Ruby 写的吗?

#29 楼 @Alex_Cheng 前端采用 Semantic-UI + AngularJS , 后端主要采用 SpringMVC,也有使用 Go、Ruby 等,数据库用 MariaDB~

#28 楼 @zw963 你好,问你个问题,看过你发的帖子,关于 textmate 自动补全代码,怎么设置啊,看了你的那个帖子还是没搞明白,望指点,谢谢~

#31 楼 @suchiva 你搞错啦。我没用过 textmate.

#8 楼 @treasurelife 思维要灵活嘛,都是一个大致相同的意思~

You need to Sign in before reply, if you don't have an account, please Sign up first.