重复性的工作可能会困扰到很多人,但其实我们稍微优化下脚本,增加些容错性的等待重试,可能就会减少我们大量的重复性工作。(已做脱敏处理)
背景公司这边很多工具和程序都没有等待和重试的习惯,这非常不好,由于糟糕的网络环境和其他不可控的因素,失败的概率非常大,人工介入并修复的时候,不可避免地为移动联通做了很多不必要的贡献,也不可避免地让同事们的工作更加饱和。当然,也可能会多增加几根白发,灯光下熠熠生辉魅力无限。
所以,缺少等待和重试带来的额外工作量其实是很大的,随便举两个例子:
自动化下线平台印象中的自动化下线,本来就是一键提交的事情,但对于我们现有下线中的20个子流程来说,碰到失败是很频繁的,总需要人工介入很多次。印象最深的,就是流程失败了,然后在页面里重试几次就成功了。
这就比较让人无语,既然手工重试可以解决的事情,api接口为啥不去实现一些自动重试的逻辑呢?
当然了,下线平台还有其他问题可以扯,原以为只需将包含了IP的文本粘贴过去,程序会自动提取文本里的IP串,并自动去拉取IP相关的信息,但事实上是要自行去组织包含ip、cicode等信息的json串才能提交,无形中带来了很大的麻烦,需要用户自行在本地维护一份cmdb信息。
在20多个流程中,还会经常碰到没有按要求修改的类似cmdb的管理类信息,没杀掉的进程和端口。对于进程和端口,为啥要杀掉呢?何况有时候还需要申请root权限才能杀其他用户下的进程。另外,如果机器下线错了,恢复的时候又是手忙脚乱。其实,对于linux,下线的时候用iptables将网络一封锁,只保留ssh登录不就行了吗?恢复的时候也只需将iptables策略恢复掉就行了,无需担忧进程和端口恢复的问题,省事,不过这就扯远了。
批量导数失败另外,频繁碰到的故障就是上游的资源文件落地晚了,数据导入失败了,无论是凌晨还是深夜,只要一看到告警,就得人工响应,响应不及时徒增烦恼,忙忙碌碌,几个人几个小时就过去了。
这就很让人纳闷了,传送数据失败了或者出错了,重传几次不就完了吗?批量导数之前如果源文件没过来,那原地等待一直重试不就完了吗?当然了,重传和等待的时间也是要控制下的,不能真正影响到业务。
也许有人担心,数据要人工确认才放心,这就有些过滤了,其实程序比人可靠多了,人动不动就犯错,经过检验的程序只会按部就班忠实地执行安排给它的指令。
有时候,稍微增加这些重传重试的逻辑,就可能减少80%以上的人工介入,忙忙碌碌地做些重复性的工作,还不如看看新闻喝喝茶。
任务说明很不走运,在某个系统中,因为缺少等待和重试之类的逻辑,就会经常碰到人工介入去手工处理的事情。
有台机器上的脚本,会在每天凌晨4点左右将上游系统传递过来的数据文件批量导入到mysql表中去。悲催的是,这些文件经常性延迟,时间一过,恕不等候,所以经常性的人工介入不可避免。
下面是服务器上每天凌晨运行的bash脚本片段:
exec2$logecho"INFO执行日期:${curdate}"$logecho"INFO处理文件:$((${tab1enum}+${table1num}))"$logecho"INFO${curtime}数据导入处理开始....."$log#循环遍历写入数据foriin${tab1es1[*]}doecho"INFO${i}_${predate}.txt文件导入处理开始..."$logif[!-f"$fi1ePath/${predate}/${i}_${predate}.txt.ok"]thenfalsenum=`expr${falsenum}+1`echo"INFO${i}_${predate}.txt.ok文件不存在..."$logecho"ERROR${i}_${predate}.txt文件导入失败..."$1ogelseecho"INFO${i}中间表数据导入处理中......"$logif["${i}"="x_valuation_ts"]thenmysq1-h${dbip}-u${username}-p${password}${database}-e"deletefrom${i}_temp"$log......if["${1}"="x_valuation_ts"]thenmysq]-h${dbip}-u${username}-p${password}${database}-e"deletefrom${1}_temp"$logmysql-h${dbip}-u${username}-p${password}${database}-e"LOADDATALOCALINFILE$filePath/${predate}/${i}_${predate}.txtIGNOREINTOTABLE${i}_tempCHARACTERSETgbFIELDSTERMINATEDBY
IGNORE0LINES"$logmysql-h${dbip}-u${username}-p${password}${database}-e"deleteafrom${i}a,${i}_tempbwherea.valuation_date=b.valuation_dateanda.cmbno=b.cmbno"$logmysql-h${dbip}-u${username}-p${password}${database}-e"insertinto${i}select*froms{i}._temp"$logelif["${i}"="x_valuation_ts_ext"]......
上面的脚本,估计是开发友情协助,花了一个小时随便写出来的,其自己用java之类写的程序,肯定正规多了。
脚本的功能脚本的功能比较简单,主要做了如下事情:
变量定义:譬如目录和文件、数据库连接、时间和日期、待处理的mysq]表等;遍历表和文件:依次遍历数组里存放的表名,依次对表进行对应的sql操作,通过loaddata命令将对应的数据文件导入到mysql临时表,清理数据,完成后再插入到正式表等等一系列操作;更新状态表:导入都成功后,顺便向几个状态表插入成功标志,表明任务全完成了;记录日志:将相关输出日志记录在日志文件中;脚本的缺点但在linux的灰白界面下,上述脚本的可读性非常差,阅读这几百行代码是一件非常考验耐心的事情。
编程风格缺少缩进和空行留白,阅读起来非常困难;单行过长单行代码过长,一屏的宽度根本不够用;冗余代码过多没有用函数或其他技巧来组织代码,导致重复性代码过多,缺少留白,空间上比较拥挤,逻辑性和层次感不强;因为该脚本实在是缺少让人阅读的欲望,所以本人也一直没怎么去看,但最近告警较多,人工介入很多次,忍无可忍,只好出手重写了。
脚本改造下面,我们对比下新旧代码,依次展开说明下脚本的改造细节,大家可以看到,稍微增加些小小的重试逻辑,就会减少大量的重复性工作。
变量定义方面在bash脚本中,定义了两个数组,用来存储需要导入数据的表。
#Importtablesarraytables=("x_non_standard_security_info""x_cmb_basic_info")tablesl=("x_cash_f1ow""x_holding_ts""x_trans_dt1""x_valuation_ts""x_product_share""x_trans_tbe""x_valuation_ts_ext")
数组变量里面的项目较多,一屏的宽度就不够用,阅读起来也不方便,个人喜欢用下面这种排版方式来定义:
#Importtablesarraytables=("x_non_standard_security_info""x_cmb_basic_info")tablesl=("x_cash_f1ow""x_holding_ts""x_product_share""x_trans_dt1""x_trans_tbe""x_valuation_ts""x_valuation_ts_ext")
这样的排版,是不是清爽些了?
日志处理原有的脚本,依次将脚本的执行结果追加到日志文件中去:
exec2$logecho"INFO执行日期:${curdate}"$logecho"INFO处理文件:$((${tab1enum}+${table1num}))"$logecho"INFO${curtime}数据导入处理开始....."$log#循环遍历写入数据foriin${tab1es1[*]}doecho"INFO${i}_${predate}.txt文件导入处理开始..."$logif[!-f"$fi1ePath/${predate}/${i}_${predate}.txt.ok"]thenfalsenum=`expr${falsenum}+1`echo"INFO${i}_${predate}.txt.ok文件不存在..."$logecho"ERROR${i}_${predate}.txt文件导入失败..."$1ogelseecho"INFO${i}中间表数据导入处理中......"$logif["${i}"="x_valuation_ts"]thenmysq1-h${dbip}-u${username}-p${password}${database}-e"deletefrom${i}_temp"$log......
不是说不可以,但这样的代码比较拥挤,重复性代码比较多,代码每行过长,一屏显示不全,我们可以用函数来改造下:
##"记录日志"functionprint_info(){echo"$(date+%Y-%m-%d%H:%M:%S%s)[INFO]$*"
tee-a$log}functionprint_error(){echo"$(date+%Y-%m-%dSH:%M:%S%s)[ERROR]$*"
tee-a$1og}
如果所定义的日志级别不止INFO和ERROR两种,也可以改成下面这样:
##"记录日志"functionprint_log(){echo"$(date+%Y-%m-%d%H:%M:%S%s)[$1]${*:2}"
tee-a$log}functionprint_info(){print_logINFO$*}functionprint_error(){print_logERROR$*}......
调用的时候,像下面这样使用就好了:
print_info"开始从文件${i}_${predate}.txt导入中间表数据..."
和旧代码比较起来,稍微简洁了些,可阅读性也增强了,并且记录了每行日志的写入时间,方便排查。
SQL执行原有脚本要向mysql的表里插入数据,不可避免地牵涉到很多很长的sql语句,譬如:
if["${1}"="x_valuation_ts"]thenmysq]-h${dbip}-u${username}-p${password}${database}-e"deletefrom${1}_temp"$logmysql-h${dbip}-u${username}-p${password}${database}-e"LOADDATALOCALINFILE$filePath/${predate}/${i}_${predate}.txtIGNOREINTOTABLE${i}_tempCHARACTERSETgbFIELDSTERMINATEDBY
IGNORE0LINES"$logmysql-h${dbip}-u${username}-p${password}${database}-e"deleteafrom${i}a,${i}_tempbwherea.valuation_date=b.valuation_dateanda.cmbno=b.cmbno"$logmysql-h${dbip}-u${username}-p${password}${database}-e"insertinto${i}select*froms{i}._temp"$logelif["${i}"="x_valuation_ts_ext"]......
同样的,代码缺少留白,加上mysql的执行命令部分,比较拥挤,冗余代码过多。
于是,我们可以用函数将mysql执行语句前面的部分剥离出来,下述代码看起来就清爽多了:
case$iin"x_valuation_ts")exec_mysq1"deletefrom5{1}_temp"exec_mysq1"LOADDATALOCALINFILESfIGNOREINTOTABLE${i}_tempCHARACTERSETgbFIELDSTERMINATEDBY
IGNORE0LINES"exec_mysq1"deleteafrom${i}a,${i}_tempbwherea.valuation_date=b.valuation_dateanda.cmbno=b.cmbno"exec_mysq1"insertinto${i}select*from${i}_temp";;
为了和原有脚本兼容,避免歧义,sql部分就没有做拼接处理了,过长的语句可以用\之类的方式来折行。
另外,ifelse主要是c++等高级语言风格的判断逻辑,在bash中,可以用case语句来改写,看起来稍微有层次些。
函数exec_mysql内容如下:
##”执行mysq1语句"functionexec_mysql(){#TheIPofmysqldatabasedbip="xxx.xxx.xxx.xx"#Theusernameofmysqldatabaseusername="xxx"#Thedatebasenamewi1lbeinsertdatabase="xxx"set-fprintinfo"$*"evalmysq1-h$dbip-u$username-p$password$database-e\"$*\"set+f}
其中,原有的解密password的代码部分,也组织在函数decrypt_passwd中了,这样代码的组织性更强。
禁止路径扩展在sql代码中,用到了*号:
exec_mysql"insertinto${i}select*from${i}_temp"
而*号在bash中,属于路径扩展中的特殊字符,如果要使用其本义,则需要禁止其路径扩展。
看看我们在函数exec_mysql中是如何去写的:
set-fprintinfo"$*"evalmysq1-h$dbip-u$username-p$password$database-e\"$*\"set+f
如果不理解set指令和eval关键字的含义,可以去翻翻bash的手册或编者以前写的那份ppt文档。
重试逻辑在原有代码中,每天需要将多个文件导入到mysql中的对应的多个表中去,一旦其中任意一个文件没有在凌晨4点前落地,脚本就会退出,导入的数据就不齐全,就需要人工介入处理。
本次优化的重点就是增加了等待和重试的逻辑,在上午10点之前,如果文件还没落地,就一直等待重试,直到成功为止。
等待重试的逻辑很简单,无非就是增加了一个循环,加入部分判断和退出逻辑就好。
num=1while:;dofalsenum=0##"导入数据"foriin${tab1es1[*]};dof="$filePath/${predate}/${i}_${predate}.txt"if[!-f"$f.ok"];thenprint_error"文件$f.ok不存在,文件${i}_${predate}.txt导入失败。"((falsenum++))continuefi[-f$f.done]continue......#"针对不同的表,开始执行sq1插入"......[$?-eq0]touch$f.doneprint_info"中间表数据导入完成。"done......done
每个文件导入后,就增加一个标志,表明该文件已经被处理完成。
[$?-eq0]touch$f.done
在开始处理之前,如果发现标志文件已经存在,则退出当前级别的for循环。
[-f$f.done]continue
当然了,如果发现待处理的文件不存在,则将错误计数$falsenum递加,并跳出当前for循环。
if[!-f"$f.ok"];thenprint_error"文件$f.ok不存在,文件${i}_${predate}.txt导入失败。"((falsenum++))continuefi
注意代码中的continue指令,多用该指令就可以少嵌套一层判断逻辑,从而减少代码缩进的次数。前面说过,缩进可以增强代码的层次感和可读性,但缩进的层次太多了也不好,会给阅读带来麻烦,一般缩进3-4层就足够了。
如果错误计数$falsenum等于0,则表示全部处理成功,否则,脚本等待10分钟直到所定义的时间点$endhour。
if[$falsenum-eq0];thenexec_mysq1"update..."exec_mysql"insert..."......echo"INFO更新处理状态为已完成。"breakelsecurhour=$(date+%H)print_error"错误文件数有$falsenum个,稍后进行第$num次重试。"if[$curhour-gt$endhour];thenprint_error"时间已超过$endhour点,脚本退出。"breakfi((num++))sleepfi
执行完成后,将临时的标志文件全部删除。
rm-f$filePath/$predate/*.doneprint_info"数据导入全部处理结束!"优化结果
程序第一次运行,输出日志如下:
......-01-:50:[INFO]中间表数据导入完成。-01-:50:[ERROR]文件/data/0128/x_holding_ts_0128.txt.ok不存在,文件x_holding_ts_0128.tx导入失败。-01-:50:[INFO]开始从文件x_trans_dt1_0128.txt导入中间表数据...-01-:50:[INFO]deletefromx_trans_dt1wherethe_date=date0128-01-:50:[INFO]LOADDATALOCALINFILE/data/0128/x_trans_dt1_0128.txtIGNOREINTOTABLEx_trans_dt1CHARACTERSETgbFIELDSTERMINATEDBY
IGNORE0LINES-01-:50:[INFO]中间表导入完成。......-01-:50:[ERROR]错误文件数有1个,稍后进行第1次重试。-01-:00:[ERROR]文件/data/0128/x_holding_ts_0128.txt.ok不存在,文件x_holding_ts_0128.txt导入失败。-01-:00:[ERROR]错误文件数有1个,稍后进行第2次重试。-01-:10:[INFO]开始从文件x_ho1ding_ts_0128.txt导入中间表数据....-01-:10:[INFO]deletefromx_holding_ts_temp-01-:10:[INFO]LOADDATALOCALINFILE/data/0128/x_holding_ts_0128.txtIGNOREINTOTABLEx_ho1ding_ts_tempCHARACTERSETgbFIELDSTERMINATEDBY
IGNORE0LINGS-01-:10:[INFO]deleteafromx_holding_tsa,x_holding_ts_tempwherea.the_date=b.the_daateanda.cmbno=b.cmbno-01-:10:[INFO]insertintox_holding_tsselect*fromx_holding_ts_temp......-01-:50:[INFO]中间表数据导入完成。......-01-:50:[INFO]更新状态表结束。......-01-:10:[INFO]数据导入全部处理结束!
从输出日志中可以看到,有一个文件发生了2次重试,在20分钟以后第三次重试的时候终于处理成功,随即脚本结束,从而避免了人工介入处理,减少了人力投入。
下一步优化如果10点后文件还没落地,程序退出后,必须需要人工导数怎么办呢?
原来是维护了原有脚本的一个拷贝,额外定义了缺失数据的开始日期和结束日期,并且需要将缺失的数据拷贝到某个以当天日期命名的临时目录下,然后手工执行脚本才能修复。
其实,没必要这么麻烦,我们找到脚本中的$predate变量的定义:
predate=`(date-d"${curdate}-1days"+%Y%m%d)`
将其中的-1days,更改成一个变量即可:
predate=`(date-d"${curdate}-${preday}days"+%Y%m%d)`
当然了,为了减少变化,直接拷贝了原开发者的代码,个人更喜欢下面这样写:
predate=$(date-d"-${preday}days"+%Y%m%d)
至于变量$preday,可以先默认赋值为1,如要取其他值,可以通过指定的参数传递进来。当然,所传的参数可以通过bc指令来检查下是否是数字。
假设脚本名称为imp_xx2yy.sh,如果确认最近8天的数据都要修补,则直接通过一个for循环来依次调用本脚本即可:
#fornin{1..8};do./imp_xx2yy.shn;done
如此这样,就会将这几天的数据依次导入,不用每次再去修改脚本里的日期,也不用手工去拷贝待载入的数据文件。这样的好处就是减少了脚本的维护,简化了脚本的执行。
bash脚本小技巧如果想进一步把脚本写好,就可能会用到一些常用的小技巧,在这里顺便总结下。
技巧0:调试技巧bash是一种不大严谨的脚本语言,相比于python之类的脚本语言,在操作文件时简单快捷,所以在运维的时候经常用。但由于其没有专门的IDLE,有时候碰到一些书写错误、闭合问题、变量问题等,调试起来非常麻烦。
建议在脚本开头加上如下一段内容:
set-x-e-u-opipefail
或者其精简版:
set-xeuopipefail
调试完了再删除,或者只删除-x:
set-euopipefail
参数-e和-o的结合使用,确保脚本执行时碰到错误就退出执行;
参数-u避免使用未被定义的变量;
参数-x会在执行时会把经过展开后的详细命令都打印出来,不过输出会显得杂乱。
技巧1:慎用管道在bash中,用一次管道就会fork个子进程,fork进程当然需要消耗系统资源的,当服务器繁忙时,额外增加的子进程就会雪上加霜,严重加深了服务器的负载水平。
要想减少管道的使用,大家要尽可能地熟悉awk、sed、grep的使用。
譬如求进程数,有些人就喜欢这样用:
ps-ef
grepnginx
wc-l
其实这是没必要的,grep指令自己就有类似的参数来帮忙:
ps-ef
grep-cnginx
另外,如果熟悉awk的话,个人认为提取任何需要的信息,只需要使用1次管道就差不多了。譬如求ip