开发中IO分离设计的重构杂谈

  需求的变更和增加是程序员永远挥之不去的阴影,不过这也是保证程序员饭碗之所在,否则一个系统设计开发后就一直运行下去,那估计程序员都会成为项目制聘用了,公司才不会一直花钱把你养着供着呢。
  这不,之前开发的人工座席答案自动推荐模块项目小结,经过接近两个月的线上调试修BUG,算是挺稳定的了,现在出的新需求是:将数据暴露到后台,可以网页修改数据。这一下折腾的有点大法了,听我慢慢道来。

  之前没有考虑到会有这种需求,而且整体设计和实现都是我一人操办,所以项目架构、数据库设计都是自己任着性质来的:
  a. 主系统因为数据量比较大,所以是站点按区域分库、每个库中按时间后缀分表存储的;
  b. 我这里因为查询需要检索连续的时间、且总体数据量还不是很大,还有权限等乱七八糟的原因,所以我用的方案是:只在一个库中分表,用站点编号与16取余映射到这十六个分表中,这样可以隔时间把一部分旧数据导出到别的表,保证工作的数据表在一定时间段中连续,数据表也不过于膨胀下去。同时,还因为我的客户端设计用的数据库连接池,对同库使用十分方便,而跨库异构连接处理就有些麻烦。

  如果要让我解决这个问题,那从我的角度最省事的方式,就是在当前库中额外添加一个表,每次处理坐席请求有新数据的时候,额外增加一次他们要求的入库操作,这样他们要的信息一次查表就可以,不用做跨表和连表的复杂查询了。

  这样我是爽了,不过前端可能不哈皮了。因为之前相似的业务,都是基于主库那种分库和时间后缀表写的,即使我这种单表最简单的操作,前端也需要修改部分的代码和逻辑。结果就是前端设计跟主业务一样的分库和时间后缀表,让我这里填充这个表供他们访问。

  这样,我操作起来就会有些麻烦:我需要在处理自己业务逻辑,写自己的单库分表的同时,还需要处理其它几个分库时间后缀表,如果在当前线程位置直接插入,那同步保护将会复杂很多。当时看陈硕的《Linux 多线程服务器端编程》的时候,告诉我们如果有较多的IO任务,可以将IO任务单独开辟一个线程,把工作线程的任务都通过队列发送到单独的线程去负责IO操作,不要让IO操作降低工作线程的效率。借助这个思路,我可以做的思路是:工作线程把数据库执行语句格式化好,然后插入到队列当中,然后IO线程不断从队列中取出语句逐个执行就可以了,而IO线程是顺序执行的,可以无锁使用每个分库的连接。

  上面的思路已经算是可以了,不过还可以把思路更扩宽一些。其实很多公司的系统增长模式就是:开始在系统中慢慢增加功能,导致一个很大很复杂的系统出现,后续发现越来越难维护,也越来越难扩展的时候,再进行解耦分割成一个个单独的模块工作,模块之间通过某种方式去通信,这样不仅可以拆解成简单模块易于维护,还可以单机多进程、多级多进程随意扩展。

  于是,我也按捺不住想要尝试一下这种模式,当然还有一个原因是原先的系统是用C写的,最近对C++比较的钟爱,即使当前生产机GCC-4.4.7并不完全支持C++11(-std=c++0x),但是结合Boost库发现C++写起来还是比C要轻松很多。系统中总共有已有前端增加的网络工作线程、新开发的后端包括数据接收主线程和分库数据相等的SQL执行线程三个角色,整体的设计思路是这样的:
  a. 前面还是像上面描述的一样,libmicrohttpd工作线程在有满足条件的数据出现时候,组合需要执行的SQL语句,然后将SQL语句压入到一个任务队列中就立即返回;
  b. 开辟一个网络工作线程,负责将队列中的SQL语句不断发配给后端处理进程;
  c. 处理进程包括数据接收主线程和SQL执行线程,SQL执行线程跟分库的数量一致,同时维持一个对目标分库的数据库长连接;
  d. 数据接收主线程负责IO接收数据,得到完成数据包后,解析规定的包头得到站点号,依据规则发配到SQL执行线程的执行队列中去。

  这种非开放的后台服务端编写,还有些额外的事项需要注意和处理:
  a. 由于非开放业务,数据通信在内网自己使用,这种情况下各个模块之间的通信肯定是长连接更高效,同时也不必考虑网络安全等问题;
  b. 网络工作线程发现传输错误之后,会自动断开当前连接,并按照之前的地址不断重新尝试连接后端的处理进程,未能发送的数据在最大容量范围内还是会堆积在队列当中;
  c. 由于连接有限,所以后端的处理进程没有异步化处理,是one connection per thread且采用阻塞方式读取处理;当错误之后服务端断开当前连接,销毁错误线程,此时同上面所描述的机制,网络工作线程会自动重新连接;此处也不必过于关心性能问题,因为这个线程只负责进行IO,人家说很多情况下单线程也能把网络带宽跑满,只负责数据转发应该是比较高效的;
  d. 长连接的通信要能处理数据拆包和粘包问题,增加了复杂度。我在每个包的头部封装了两个uint32_t类型网络字节序的整数,分别是当前包的长度和对应的站点号,然后解析字段知道当前包的结束位置,同时后面分派线程的处理也容易了。其实,如果是本地回环和网络传输还是有差异的,比如之前用async_read_some,回环可以接收很长的数据,但是一旦上线,这个函数基本就只能接收一个MTU的长度。粘包测试也很简单:可以在网络工作线程堆积两个包,然后包分块慢慢发送(比如每次发64个字节等),然后查看后端主线程接收和解析包是否正常。
  e. 不怕一万就怕万一,万一由于某些因素导致连续的解析失败了(不仅仅是程序的原因),错误累积漂移会导致后面的所有业务失常。所以还需要额外增加一个检测机制作为看门狗的作用:一旦有错误发生就断开这个链接,比如解析开头的MAGIC_NUMBER、累积收到的数据大于某个值、累积接收了多少次数据包,但是这些数据包没法被消耗,就说明缓存区的包头有问题了,这个时候就应当主动断开这个链接了。
  f. 最后还有一点可以优化的是,让后端开一个网络端口后门(陈硕说过少用信号),当检测到这个后门连接并发送约定的命令时候,后端关闭侦听套接字和所有和前面网络线程的连接,等待一段时间当SQL处理线程消耗完队列中的数据后,就可以安全kill掉服务了。而前端网络线程会不断尝试连接,前面SQL处理线程可在限制的缓存数量中将请求堆积,不会造成严重的数据丢失,两者耦合性大大降低了。

  慢慢优化吧,程序员的事情是永远都做不完滴。其实项目中构架、设计还是要随大流,自我创造就是跟自己挖坑啊。。。