服务重构过程阶段性小结

  首先呢,看到这篇文章的时候,重构工作说明已经进行的差不多了,不然也没心情出来跟大家扯淡了,而前几天真是够自己忙活惨了的。总体来说业务过程不是很复杂,所以项目规模也不是很大,不过经过这个蜕变的过程,还是有些东西想说说总结一下,而且这个过程中也不可避免的造了点轮子。其实原本项目中也有很多的轮子,有些不好用、有些晦涩难懂也不敢再用,所以现在这个项目中除了将有些轮子使用boost等一些成熟库来替换,有些不好用的轮子自己也再造了一次(机密信息,所以下图被故意模糊化了)。
refactor

一、定时任务服务

  原来的定时任务服务,是采用了类似堆结构(内部用std::set然后重载compare比较器)的方法来实现的。其实堆结构实现定时器算是业内习惯性的解决方案:Libevent内部定时器就是通过堆数据结构实现的,而Nginx中对连接计时也是通过堆结构实现的,这样当各个定时任务按照到期时间进行有序排列的时候,管理器就只需要睡眠到下一个最早任务到期时间就可以了,这对于简单和类型不多的定时任务是比较合适的,但是要作为一个通用的定时任务服务,难以预估定时任务执行所需时间,对单个线程的处理能力和响应灵敏度也不好控制。
  新的TimerService没有沿用之前的设计,新的方式是基于Libevent异步事件框架,采用标准接口创建定时器事件,由于Libevent是C语言实现的事件库,所以采用一个简单标准C签名的函数进行wrapper,而在wrapper函数中调用C++的Callable对象,以应对各种各样的定时任务。同时,这个服务中还创建一个事件线程和N个普通工作线程组,前者负责Event Loop,同时对于简单的任务就直接内联执行;而对于重量级、实时性要求不高的任务就放到一个队列中丢给工作线程组慢慢消耗,当然这也就是借鉴了Linux内核中断上下部分的模式。
  这个定时任务服务高度采用了boost::bind、boost::function工具,所以很多可调用对象可以按照传值的形式进行保存和拷贝。然后工作线程组也用了下面即将介绍的线程池模型实现的,后续可以根据队列堆积的情况进行线程数目的自动伸缩,不过如果工作线程组引入多线程机制,那么添加的任务就需要考虑重入性和线程安全的问题,又增加了一些复杂度了。

二、线程池

  线程池在项目中被大量的使用,所以我想一个好的线程池模型成功之后将会有巨大收益。当然,传统固定线程组加任务队列就能够解决任务需求了,但是我们还想有的功能有:通过开关可以暂停线程消费任务,这在任务进行测试、紧急情况冻结服务、服务优雅下线的过程中将会很有意义;同时线程虽然相比进程已经轻量级很多了,但是近几年协程的大量出现折射出线程也被嫌弃过于重量级了,在一定情况下服务中产生大量线程时候,系统资源相当部分会用于线程上下文切换过程中,所以能够按需自动伸缩线程池容量将是一件低碳、高效的工作模式。
  针对上面的第一个问题,我们为线程设计了Init、Run、Suspend、Terminate、Dead各种状态,默认通过boost::thread创建线程的时候线程就立即执行了,为了可以手动控制线程实际任务的执行,我们在线程执行体外部额外做了一个wrapper,只有在手动启动开启工作模式的时候才会开始运行真正的任务部分。线程执行函数bind了一个状态体指针,这样外部线程就可以和内部线程进行状态同步,通过外部函数控制这个状态体的值,内部线程就知道什么状态需要continue暂停、什么状态需要退出执行循环体了。为了达到更好的效果,线程函数在从任务队列消费任务的时候,最好采用带超时值而非无限阻塞方式去取任务,因为让线程无限阻塞到获取任务的时候,相应的状态就无法查看和更新了。
  现在的云主机厂商都号召弹性按需服务模式了,所以如果线程组能够实现这样的功能将会是很Exciting的事情。当需要结束线程的时候,外部管理者将某个线程置为Terminate的状态(可以选择任意的线程,因为这些线程组都是同构的),当工作线程消费完当前任务的时候,检查到这个标志就退出循环体,同时将自己置为Dead;外部主线程执行timed_join正常返回后,检查到这个标志就可以安全删除boost::thread结构了,从而缩减了线程的个数。
  因为通常情况下线程负载的计算模型比较难确定,所以还不考虑做自动伸缩,可以通过修改并加载配置文件,实现手动修改线程组的容量。

三、配置的自动更新

  一般成熟的大型公司都会有成熟庞大的配置中心服务,而小公司当然是怎么方便怎么干,通常配置保存的位置可以是文件、数据库、Redis等位置。如果是文件的话服务可以开启配置文件侦听机制,发现文件修改后自动重新加载,但是个人还是用的比较传统保险的方式——当修改配置后,通过手动向程序发送信号的方式触发程序重新加载配置并进行相应更新。但是整个服务涉及到的模块比较多,如果将这些依赖进行硬编码估计以后又会慢慢滑入老路:增加一个通道或者增加一个服务,需要依次序在位置A、位置B、位置C……添加代码,这种事情做多了真想剁手。
  现在的方式是形成一个ConfigHelper单例,在信号响应函数中触发这个单例的某个成员函数,该成员函数首先从已知位置加载所有的配置项,然后以新配置作为参数依次调用一个std::vector中的所有可执行对象。说到这里大家就立刻明白了,当任何一个服务或者对象想要收到配置更新通知的时候,只需要通过这个单例的接口向这个容器中注册自己的回调函数就可以了,等于实现了一种发布和订阅的关系模型吧。
  我知道,最牛逼的方式就是搞ZooKeeper做服务发现和配置自动更新的,这个有再弄吧。

四、服务的多进程安全和系统状态统计

  多进程安全的需求一方面可以实现容灾备份,同时也可以实现弹性伸缩,而且多进程部署实现后,多机器、跨机房部署也将不是问题,由于我们的服务是提交、查询二段式过程,之间差距的时间比较长,就必然需要将两个过程分开来执行。提交的过程是从MQ读任务,这个过程天然是多线程、多进程安全的;查询的过程是从数据库中去取任务,这个过程以前一个同事的实现方法值得借鉴:由于提交任务和查询任务是对应的,在提交的过程中从临时表中创建一个任务条目,并将其状态设置为可消费状态,然后多个进程去取任务的时候,通过在一个事务中使用SELECT FOR UPDATE方式取出一定条目的任务,并在这个事务中将任务的状态置为处理中状态,然后进行提交或者回滚操作,通过这个机制MySQL能够保证相同的任务只会被一个客户端获取并更新。现在看看SELECT FOR UPDATE真是个好东西,在一个事务中他会锁住WHERE的整个条件范围,事务期间任何满足该条件范围的插入、删除、更新都将会被阻塞住。
  整个系统虽然规模不大,但是涉及到的服务和调用比较多,比如MQ的消费、Thrift RPC调用,记录这些调用的时间速率对于运维、运营将会有很大的指导意义。通过之前阅读《Redis实战》觉得使用Redis来做这个工作比较的合适,这货速度快、不跟业务耦合、数据结构和操作接口丰富,做这个事情最合适不过了。对每个统计条目按照时间建立一个LIST,然后将每个调用所花费的事件RPUSH进去,后续就可以计算任意时间点的任意调用次数、速率、方差等信息了。还有,如果每次调用都在其周围手动写开始开始、结束计时的代码,未免太繁琐了,其实可以用C++对象声明周期的特性,在构造的开始记录时间,在超出作用域析构的时候获取结束时间,那么问题是不是就简单的多了呢?我就提示到此吧。
  PS:这种多进程安全的操作,最终感觉还是用分布式系统成熟解决方案比较适合……

更新
  服务上线啦!可见在我们进行T1结算的时候,MQ发送和消费消息的速率显著增加,但是消息Queue的数量没有明显的变化,说明没有消息堆积,消费者的处理能力不弱于生产者的能力,所以MQ削峰填谷的功能也没法体现了。现网消息消费的高峰是35tps,保守估计其最大吞吐量能达到160tps(从消费者线程、消费速度估算),也就是比原先的老系统提升了至少一个数量级的性能。至于实际性能能有多少我也不知道,因为公司只分配了一个虚拟机做测试机,上面跑十几个服务一开测就基本被数据库卡死了,一个物理测试机申请了几个月了老板不肯批!不过貌似同事都习以为常了。
bank
  现在打款服务稳定了,后面的优化就是路由服务和其他周边服务了。当需要更新或者Bug需要hotfix的时候,只需要打款系统把MQ消费关掉,后面的服务就可以安全更新重启了,而上游的调用服务无感知。所以,现在我司可以安全的尝试24小时T0交易服务了。

  还有一个坑点发生在我们的ID发号器服务中,发号器通过递增的方式保证每一天产生的号是不重复的,所以我们就需要额外的关注“日切”这个话题。在实现上,日切发生的条件是检查本地日期和记录更新日期是否一致,这本身没有什么问题,但是关键修改数据库记录时候是使用的NOW()函数去更新时间的,其代表的是数据库本地时间而非服务器的时候。在这种情况下如果本地服务器和数据库之间时钟不同步的话,在这个时间差中就会反复出现重新发号的情况,所以这种严重依赖时间行为,需要保证时间的一致,最简单的方法就是全部采用服务器上的时间进行计算和判断。

小结

  当然,上面的这些东西基本是个人的一厢情愿,虽然实现的差不多了,但是还没有经过相关领导的评审,同时也还没有进行相关测试和线上验证。期待好消息吧!

本文完!