近期,协助朋友公司调优了一个奇葩的系统。这个系统是当时公司外包几个计算机毕业生弄出来的,对一堆视频录像文件(AVI)进行处理,执行不同的筛选任务。本来朋友是叫我去指导重构的,看到现状果断放弃。
但作为一个轻易不出刀,一出刀就要劈友的骨灰级现场工程师,临阵脱逃是有损江湖威望的。 最终,通过多管齐下,内存磁盘 ImDisk + NTFS 文件夹链接+现场撸配额控制与强制删除,硬是在1天内恢复环境,随行跟班小广东大呼“大晒”,现场工程师发威,遥想当年洛夫罗。
1 初步了解情况:坚决不能动代码
首先被告知,做系统的主要团队已经解散了,只能找到部分成员,代码文档也不完整。朋友说:能够重构是最好的。我仔细研究了1天,惊喜的发现这个系统把小作坊能出的问题都出了,就是反面教材的教科书:
- 没有顶层设计。一看就是几个基友QQ沟通攒起来的。每种识别算法都是独立的可执行文件,会自顾自地读文件、产生结果文件。全部依靠定时器扫描文件夹处理,处理完成后改名或者搬移走来触发下一步流程。
- 没有数据规范。同类的算法,比如边缘检测和亮斑检测,产生的结果文件格式不同,一个是BMP格式的切片,一个是matlab的.mat, 还有的是TXT文件(行列坐标)且没有文档。
- 运行时与开发环境不切割。主干是openCV的C++程序,但还调用matlab引擎和python脚本。VC的程序是Debug版的,直接在Debug文件下启动。
- 没有提供完整源码。朋友不懂技术,说是外包前说过要提交源码。源码文件夹只有少量python代码和BAT的批处理脚本,被忽悠了。大量C/C++模块只有Debug文件就和dll和exe, Matlab的源码倒是有,但是不多。
- 文件夹配置混乱。关键的数据交换文件夹一定是E:\AVI, 没有配置文件可以修改。一些处理文件夹不能有汉字和空格,但是另一些dll的名字又是 “边缘处理1.dll"。
- 配置文件位置不统一。位置扔的乱七八糟。配置文件格式不统一,xml\json\ini\txt都有。
- 数据流转混乱。依靠磁盘,共享文件夹、FTP交换数据。路径、用户名口令全部写死,配置文件只能改IP地址。
- 数据库设计缺陷。绿色版的mysql,一个文件夹。但是数据库就1个表,和excel没有差别。表里没有索引,数据模型冗余,毫无范式可言。
去重构?这项目我是不可能去重构的。引用下面的图轻松一下!
![]() | ![]() | ![]() |
---|
2 并发读写-机械磁盘阵列的软肋
这个系统虽然有这么多问题,但当时还是通过了测试。据朋友说,用的是4路网络摄像头采集的视频文件,测试了72小时木有问题。这充分说明,小作坊做的软件,还是堪用的。那为什么又抓狂了呢?有朋友自己的原因,因为该系统原本部署的环境和现场的环境不同。朋友不懂技术,觉得都是摄像头,没有问题。带着尚在公司的剩余成员(布线的),多接了一个客户,但是到现场部署后,才发现摄像头都是闭路高清的,且多了4组。
遇到的主要问题是盘阵罢工。视频来不及写入,程序来不及处理。生产环境的计算机配置不赖。虽然是攒的山寨服务器,却有128G内存,以及一个10TB的RAID-5盘阵。组成盘阵的是西X的7200转。根据初步估算,光纤加持应该是来的及存储的。为什么会发生这样的事情呢?
通过资源监视器,观察到工作状态下,竟然有30多个进程同时访问多个视频文件和中间结果文件。通过现场询问尚能到场的原始团队成员,了解到各类处理进程都是独立读写文件,导致对同一份数据,会读取N次。由于算法无法实时处理,团队也是下了功夫的。他们采用了一个多进程的松散队列,如下图所示:
- 视频采集软件每分钟生产8个AVI文件,一个对应一个摄像头。各个功能进程组成多级的处理群,排队处理。
- 除了第一组功能只访问原始文件,后续很多功能都需要访问前面生产的结果文件,少量进程是既要原始文件,又要前序结果文件。
- 结果文件有的很大、有的很碎片化,结果就是“又大又碎片化”。
- 进程之间没有交互,都是靠扫描文件的方式处理。因此,当需要的各层文件不齐全时,会等待几秒再次扫描。
- 图上没有画出的是数据库。比起文件的流量,数据库那一点流量和范式问题可以不去理会。
因为是高清视频,为了实时处理,队列中的功能进程比原本配置提高了4倍,共有30多个。
2.1 机械盘阵首先是“机械”
盘阵速度快,是因为各个硬盘分享了整体的数据流量。但如果组成盘阵的成员是机械硬盘,则需要非常注意随机存取的问题!机械磁盘的顺序读写是很快的,但随机读写会导致磁头的寻道时间陡增,延迟和速度会下降。
这个文章详细介绍了机械盘阵的随机存取性能比较,可以参考。
这里引用文中插图:
实际测试现场的盘阵,比上图还要糟糕:随机并发读写下,盘阵缓存无法命中,导致各个磁盘疯狂寻道,效率下降100倍。在现场30多个进程并发读写的场景下,RAID延迟巨大无比,导致文件夹都打不开。
2.2 可行方案-二级架构固态盘阵
一种不去动现有程序的方案,是使用固态盘阵作为介质。稍微经济一些,则考虑采用固态缓存+机械盘阵的二级结构:
- 原始数据直接落固态盘阵
- 所有的中间处理都在固态盘阵
- 最终结果顺序备份到机械盘阵
固态硬盘现在也有M2的TB级别硬盘,1万之内可以搞定。但由于朋友希望现场0元解决这个问题,只能再想办法。
3 现场解决方案
3.1 内存虚拟磁盘ImDisk替代固态盘
由于看到这台工作站是128GB土豪内存,想到现场下载内存虚拟磁盘ImDisk虚拟出一个内存盘来。
二话不说,下载一个:
https://sourceforge.net/projects/imdisk-toolkit/
安装后,直接开辟一个64GB的虚拟磁盘,作为A盘(我有软驱怀旧综合症)
注意,
- 把临时文件夹选项去掉,我们的盘专门用于处理视频。
- 把动态内存分配钩上,这样可以减少内存消耗。
创建后,我的电脑就看到了A盘。
3.2 NTFS链接工具junction 挂载链接
当安装了A:盘后,惊喜的发现,系统的处理文件就只能放在E:\AVI。已经辞职的程序猿拒绝修改代码,没办法,当时签的野合同,无法通过合同追溯,这太糟糕了。怎么办?!
如果是Linux,直接ln -s就可以了。不过我们在windows下。好在,windows下的NTFS文件夹支持类似的功能。下载工具 junction即可:
https://docs.microsoft.com/en-us/sysinternals/downloads/junction
用法很简单:
删除E:\AVI,而后:
C:\>junction.exe E:\AVI A:\
junction
此时,你会发现E:\AVI又出现了,但是多了一个小的快捷方式图标。
3.3 开发简单配额控制与搬运工具
这个磁盘只有64GB, 只能存储一小会儿。如何保证数据被及时搬走呢?
团队运维现场撸了一个同步程序,主要功能:
- 监视文件夹(内存盘),发现新的结果文件后立刻拷贝到目的文件夹(盘阵)
- 统计内存盘所有文件大小,按照时戳排序。
- 当大小超过配额(比如56GB),则删除最老的文件,直到配额满足要求。
3.3.1 枚举文件
使用Qt 的工具,可以枚举文件夹下的所有文件。
为了配额时删除最旧的文件,采用字典进行自动时刻排序。
std::map<qint64,QMap<QString,QFileInfo> > m_cache_files;
qint64 m_total_size = 0;
m_cache_files这个字典的键是毫秒时刻,值为一个映射,文件绝对路径和文件信息。
同时,设置一个整形m_total_size记录文件夹的总大小。
有了上述数据结构,直接递归枚举所有文件夹。
QFileInfoList DialogFileLoadCtrl::enumFiles(QString dirS)
{
QStringList lst;
lst<< "*.*";
lst<< "*";
QDir dir(dirS);
dir.makeAbsolute();
QFileInfoList linfo = dir.entryInfoList(lst);
QFileInfoList lstRes;
foreach(QFileInfo i, linfo)
{
if (i.isDir())
{
QString dn = i.fileName();
if (dn=="." ||dn==".." )
continue;
QFileInfoList lst_sub = enumFiles(i.absoluteFilePath());
std::copy(lst_sub.begin(),lst_sub.end(),std::back_inserter(lstRes));
}
else
lstRes << i;
}
slot_next_prg(0);
return lstRes;
}
void DialogFileLoadCtrl::updateMap()
{
QFileInfoList lst = enumFiles(ui->lineEdit_src_dir->text());
m_total_size = 0;
//......
foreach (QFileInfo i, lst)
{
QString fm = i.absoluteFilePath();
unsigned long long sz = i.size();
qint64 tm = i.fileTime(QFile::FileBirthTime).toMSecsSinceEpoch();
//新文件直接拷走一份备份,防止内存断电
if (!m_cache_files[tm].contains(fm))
{
//...
TransFile(i);
}
//记录字典
m_cache_files[tm][fm] = i;
m_total_size += sz;
++m_total_files;
}
}
3.3.2 搬运文件
搬运文件注意的是要恢复并创建文件夹结构。Qt的QDir基本上解决了一切需求:
void DialogFileLoadCtrl::TransFile(QFileInfo ifile)
{
QDir dirSrc(ui->lineEdit_src_dir->text());
//获取相对监视根文件夹,现有文件的相对路径。
QString srcFile = dirSrc.relativeFilePath(ifile.absoluteFilePath());
QString relDir = dirSrc.relativeFilePath(ifile.absolutePath());
//以备份目的根文件夹为起点,恢复目的文件的绝对路径
QDir dirDst(ui->lineEdit_dst_dir->text());
QString dstDir = dirDst.absoluteFilePath(relDir);
QString dstFile = dirDst.absoluteFilePath(srcFile);
//mkpath 直接创建一串路径
QFileInfo info(dstDir);
if (!info.exists())
dirDst.mkpath(dstDir);
//拷贝文件
//...
QFile::copy(ifile.absoluteFilePath(),dstFile);
}
3.3.3 删除确保配额
定期检查总大小,删除最旧的文件:
void DialogFileLoadCtrl::cleanFile()
{
while (m_total_size > MAX_SIZE/*16GB in Bytes*/)
{
if (!m_cache_files.size())
break;
qint64 tmf = m_cache_files.begin()->first;
QMap<QString,QFileInfo> & mp = m_cache_files[tmf];
if (!mp.size())
m_cache_files.erase(tmf);
else
{
QString fm = mp.begin().key();
QFileInfo ifi = mp.begin().value();
if (QFile::remove(fm))
m_total_size -= ifi.size();
mp.remove(fm);
//.实际情况可调用https://github.com/michaelknigge/forcedel确保强制删除
if (!mp.size())
m_cache_files.erase(tmf);
}
}
}
3.4 强制删除过期文件
有了上述操作,基本搞定了部署。但是,天杀的发现有一个程序只打开文件,有时不关闭文件,导致其打开的中间结果,在正常删除环节删不掉。怎么办呢?强制删除工具很多,但免费能获取的命令行工具屈指可数。现场搜索了1个小时,找到了神器:使用.net命令行工具
https://github.com/michaelknigge/forcedel
强制删除没有关闭的文件。用法很简单, 暴力删除10次!
for (int itry=0;itry<10 &&QFileInfo::exists(filename);++itry)
{
QStringList a;
a<<filename;
QProcess::execute("ForceDel.exe",a);
QThread::msleep(200);
}
Windows下调用ForceDel开源工具,要安装.Net3.5.在Windows Server 2012以上,默认是木有的,要把安装光盘插进去,在角色与功能里安装。或者拷贝安装光盘的SxS文件夹到本地,指定备份源位置。
4 总结
经过改造,64个处理器核心占用率80%,基本满足实时处理需求。相关代码见
我的gitcode.net代码仓库
无论如何,上述补救措施都是治标不治本的措施。但这次运维很有意思,告诉我三个事情:
- 如果开发团队很差劲,有超级厉害的运维团队,往往也能把项目跑起来。对于小公司来说,追求“堪用”已经很不容易了。不知道有多少线上的光鲜页面背后,是焦头烂额的运维和一堆重启脚本。
- 内存映射盘真香,有了这个东西,文件接口的优势就来了!简单啊!一堆fread fwrite就暴力搞定了!
- 当老板可以不懂技术,但一定要认识懂技术的自己人。