前几个月开发了一个服务器网关程序,测试过程中发现了一些问题,问题的查找与修改花了一个月的时间。好在经过一个月的修改与完善终于解决了所有问题。现在网关服务器在局域网内吞吐量最高可以达到120MB/S,稳定运行7*24小时,性能和稳定性都让人满意。 然而其中遇到的问题也不少,现在回想起解决过程是痛并快乐着,以此文作一个经验教训的总结。
先说下项目背景,项目要求开发一个高性能的跨平台的网关服务器,主要性能指标是吞吐量达到70-80MB/s,并发连接数要到3000以上。现有的服务器性能达不到要求,最多不过30-40MB/s。这个项目也是我到新部门之后的首秀,也受到领导的高度关注。作为自己的首秀当然希望能充分发挥自己的能力,高质量的完成这个项目。项目开始就受到领导的高度关注和期待,我也是信心满满。先是技术选型,因为要考虑跨平台和高性能,最终决定采用asio作为底层通信库。然后业务分析、需求分析、系统设计。刚开始我的设计目标是做得通用灵活易扩展,不仅仅是为了当前的项目,还要在今后能在其他项目中复用,成为一个平台,因此灵活性、通用性和可扩展性很重要,设计时就引入了AOP、IOC、插件、消息总线等基础框架。具体设计上是分层,将底层通信与业务逻辑分开,业务逻辑采用插件方式,可以方便的实现业务逻辑的扩展和替换;业务类可以用IOC框架动态配置,以满足不同的应用场景;通过AOP将非核心逻辑和核心逻辑分离开,提高可维护性;通过消息总线统一管理所有对象之间的通信,避免对象间复杂的调用关系。设计中充斥了大量的设计模式,初步数了一下,大概用到了十二三种设计模式。这对于一个平台级规模的软件来说也不算复杂,设计充分考虑到了可扩展性、可维护性和灵活性。
在完成设计后的概要设计评审会上,当我向参与评审的十几位领导同事侃侃而谈时,下面一片沉默,因为很多人都没听说过AOP、IOC等概念,也几乎没有提出设计上有什么不足。最后大领导问要多少人多长时间做完。我说4、5个人三四个月吧。结果领导说了一句让我大跌眼镜的话,只能给我两个人,一个半月能不能做个基本的东西出来,后面再继续完善,因为另外一个项目等着用。这是典型的中国式项目开发,只想着尽快完成,至于设计是否巧妙、质量是否高不是很关心。我只得无奈的说如果要一个多月的话,那很多东西就要从设计中砍掉,也不能考虑那么多质量属性了,比如平台化、扩扩展性和灵活性等等。领导说没关系,先做个基本的出来。没办法,这种事我也见怪不怪了,只是没想到时间那么紧。项目开工了还是抓点紧干吧。先是学习asio,接着开发需要用到的各种基础框架和基础库,虽然项目给的时间很短,但我还是想按照我的设计去开发,时间不够就加下班了,做就做好,不要打折,我不想做一个自己不满意的产品出来。等asio会用了、基础框架搭好了、基础库完成了都过了一个月了。剩下的半个月赶紧将具体业务逻辑引入进来。中间还要感谢我的直接领导给我争取了一些时间,最终是两个多月把项目做完了。网关服务器的各项性能都达到要求了,吞吐量都远高于设计目标,任务算是初步完成了。下图是吞吐量和连接数测试过程中的截图。
接着一个任务又来了,算是网关服务器的一个具体应用:多个客户端往服务器发送解码数据,服务器将数据转发到其他解码的客户端,解码完之后再回发到服务器,服务器再将解码之后的数据转发到最终的客户端那里。这个应用稍微有点复杂,涉及的方面比较多,有客户端发送方,有解码方,还有接收方。果然在开发过程遇到各种问题,先是解码方经常挂掉,服务器有时候也挂掉,一时间问题集中出现,把人搞得焦头烂额。经过问题排查,服务器挂了有两个原因,一个是服务器收到错误包时异常处理不足导致的,另外一个是客户端断开连接后,服务器的asio异步操作还不知道,结果异步回调回来的时候对象本身已经析构,就报错了。这个问题花了不少时间解决,其实解决办法比较简单,就是保持对象的生命周期就行了,通过shared_from_this解决。解决服务器的问题之后,又要解决解码客户端挂了的问题,发现是解码器有问题,然后又是各种改,终于没有挂掉的现象出现了,松了一口气。不久发现又出现一个诡异的问题:系统运行一段时间后,所有的进程都阻塞住了,也不收数据也不发数据,也没有报错。真是诡异,然后又是各种查,怀疑是解码器的问题,干脆就不解码,将数据直接回发到服务器,但仍然出现这个问题。然后各种可能的地方都打印调试,还是没解决问题,这时是最让人沮丧的时候,我开始怀疑之前所有的代码,因为觉得设计得有点复杂,调试起来还挺麻烦,感觉自己被自己坑了,干嘛搞得这么复杂,甚至有了重写的念头,就这样毫无头绪的过了一周还是没有进展。最后冷静的分析了一下,觉得有可能是socket死锁造成的,当一个连接双工通信时,即这个连接又发数据又收数据,并且数据量很大时,造成对方的接收缓冲区满了,这时两边的发送都阻塞在发送了,死锁发生了!想清楚这个问题之后,就改成单工通信了,双工通信的话就建两个连接, 一个收一个发,这样就不会出现死锁问题了。又可以松口气了。
不料又出现一个诡异的问题,解码开启之后又出现所有进程阻塞的情况。刚开始以为是解码器内部多线程死锁导致的,然后各种改,保证线程安全不死锁,情况仍然出现,按理说也不应该是socket死锁,因为已经改为两个连接分别去收发了,而且将回发的数据发到另外一个服务器就不会出现阻塞。又是各种查,还是没找到原因,这时甚至开始怀疑asio是不是有bug。最后决定调试到asio内部去看看到底死在哪里了,结果让人很意外的发现两边还是阻塞在发送那里了,还是出现了socket死锁!天,两个连接都会产生socket死锁,简直要让人抓狂了。为什么两个连接还会出现死锁?其实原因还是在解码。因为解码比较耗时导致服务器发数据会被阻塞,而客户端解码完之后回发时,io_service因为还在阻塞着,导致接收缓冲区慢慢的填满了,在某个时刻两边的接收缓冲区都满了,就都又阻塞在发送了,死锁再一次发生了!本质上是因为一个io_service时两个连接依然会产生一个环,形成死锁。解决办法是启用两个io_service,消除这个环就能避免死锁了。这时出现一个比较囧的问题,由于对象间通信都是通过消息总线,而消息总线是一个单例,使用两个含io_service对象时会导致重复注册,这时消息总线的副作用出现了,虽然它能很好的解耦对象间复杂的关系,但也带来了调试不直观,相同对象出现重复注册的情况,这也是当初设计时始料未及的问题,好在问题不大,最后通过两个io_service来消除环,解决死锁问题,现在系统运行得很稳定。 再回过头来看,让人感慨写一个高性能又稳定的服务器程序真是不容易啊。中间解决问题的过程一波三折,充满挑战,从开始的烦恼、怀疑到后面的从容与自信,最后终于解决所有问题,觉得还是挺有成就感的,呵呵。
教训之一:不要过渡设计,不要玩太多技巧;
由于大量的引入了一些框架和设计模式,导致调试的时候不方便,查问题的时候也要绕半天,最后发现其实现实没有那么多变化,不需要这么复杂的设计。灵活性和高可扩展性是有代价的,程序在满足当前需求且够用的前提下,应该尽可能的简洁明了,不要玩那么多技巧,所谓“重剑无锋,大巧不工”,认真考虑一下一个系统是否有那么多变化,不要考虑“夸夸其谈的未来性”,够用就好,简单就好,这样好处很多,一来方便调试,二来方便维护,三来减少犯错误的可能。我在后面的代码重构过程中摒弃了很多模式和框架,只要求简单够用,如果将来真的有变化的时候我再重构到模式、应用一些框架也不迟。再重温大师Kent Beck提出的编程中的价值观:沟通,简单,灵活。其中简单和灵活是排在前面的,这是很有道理的。
教训之二:要注意asio的一些坑;
一个io_service时,双工通信或者多个socket发送,通过io_service形成的环可能会造成死锁,需要注意。解决办法是消除环。
经验之一:保持代码的高质量;
我对自己的代码还是满意的,虽然时间那么紧,但我仍然坚持我的编码原则,并且在有空时用代码检查工具检查我的代码,发现有问题不合原则的代码马上重构。在此也分享一下自己的编码原则:
1、过长的函数(不能超过12行)
2、重复代码(3行重复的代码)
3、类过大(不能超过500行);
4、过长的逻辑表达式(表达式不超过两个逻辑体;表达式文本长度不超过40个字符);
5、不能有魔法数;
6、圈复杂度不能超过5;
7、函数参数列表过多(参数列表不要超过5个);
8、switch语句,尽量不用;
9、过多的注释;
最后代码检查,都符合上述原则,这也是我比较满意的地方,不要以时间紧为借口,编写高质量的代码要成为一种习惯、一种责任,要为自己的代码负责。
经验之二:不要为自己的懒惰找借口,一定要重构。
当嗅到代码的坏味道的时候,一定要重构,不要怕麻烦,代码是会慢慢腐化的,坏味道的代码就是技术债务,必须定期清理,不处理的话,腐化会越来越快。重构是为了让事情做得更好,通过重构可以改善设计、提高代码可读性、减少错误,提高软件质量。哪怕当前的代码可以工作,也要经常重构,保持对代码坏味道的警惕性。
c++11 boost技术交流群:296561497,欢迎大家来交流技术。