共计 7029 个字符,预计需要花费 18 分钟才能阅读完成。
前言
履约治理是一个面向物流商家的 OMS 工作台,自从初代目把架子搭起来之后,就没有持续投入了,起初始终是合作伙伴同学在负责日常保护和需要撑持。通过几年的横蛮成长,零碎曾经杂草丛生,乱象百出。再起初,甚至一度成为一块无主之地,走行业共建的形式来反对。对于一个不反对行业隔离的零碎,行业共建象征这个零碎将疾速腐化。两年前我开始接管履约治理,来到这片广大的蛮荒之地,正如所有那些渴望造物乐趣并且手里刚好有锤子镰刀的人,我就像一匹脱缰的野马,脑子里常常会产生很多大胆且离奇的想法,心愿借此把履约治理打造成一个完满的零碎。只惋惜真正可能付诸实践的少之又少,本篇就是为数不多得以落地,并且有相当实用价值 idea 中的一个,整理出来分享给有须要的同学做参考。
日志乱象
日志是日常开发中最有可能被忽视,最容易被滥用的一个模块。被忽视是因为打日志切实是一个再简略不过的事,前人设计好了一个 logback.xml,前面只须要如法炮制定义一个 logger,顺手一个 info 调用就搞定,他甚至不确定这条日志能不能打进去,也不晓得会打在哪个文件,反正先跑一次试试,不行就换 error。被滥用是因为不同场景日志的格局内容千差万别,或者说日志打法太灵便,太随便了,格调太多样化了,以至于简直每个人一言不合就要本人写一个 LogUtil,我见过最夸大的,一个零碎中用于打日志的工具类,有二三十个之多,前人纠结该用哪个工具可能就要做半个小时的思想斗争,完满诠释了什么叫破窗效应。最好的学习形式就是通过反面教材吸取教训,上面咱们列举一些最常见的日志设计开发过程中的问题。
分类之乱
一般来说,一个零碎必然须要设计多个日志文件以辨别不同业务或场景,不可能所有的日志都打到一个文件里。然而怎么进行分类,没人通知咱们,于是就有了各种各样的分类。按零碎模块分。这种分类应该是最根底的一种分类,也是最有层次感的分类。比方履约服务中枢的零碎分层。基本上每一层对应一个日志文件。
按租户身份分。个别中台零碎都会反对多个租户 (行业),每一个租户独自对应一个日志文件。这种分类个别不会独自应用,除非你要做齐全意义上的租户隔离。意识流分类法。不合乎 MECE 法令,没有清晰对立的分类逻辑,按业务分,按零碎模块分,按接口能力分,按新老链路分,各种分法的影子都能看到,后果就是分进去几十个文件,打日志的人基本就不晓得这一行的日志会打进哪个文件。以上说的各种分类形式,都不是相对纯正的,因为无论哪一种,无论一开始设计的如许边界清晰,随着工夫的推动,最初都会演变为一个大杂烩。
- 某人心愿独自监控某个类产生的日志,新增日志文件;
- 新增了一个业务,比方一盘货,想独自监控,新增日志文件;
- 发动了一场服务化战斗,针对服务化链路独自监控,新增日志文件;
- 某个业务想采集用户行为,又不想全接日志音讯,新增日志文件;
- 资损敞口的场景,须要特地关注,新增日志文件;
- 非凡期间内产生的日志,比方大促,新增日志文件;
凡此种种,不一而足。发现没有,总有那么一瞬间能让人产生新增日志文件的神经激动,他们的诉求和场景也不堪称不合理,只管这些日志的维度齐全不相干,然而没有什么能阻止这种激动。最开始的那一套日志设计,就像一个濒临死亡的大象,一直地被不同的利益方从身上扯下一块分去。
格局之乱
对于日志须要有肯定的格局这点置信没有人会有异议,格局的乱象次要体现在两个方面,一个是格局的设计上,有些零碎设计了非常复杂的格局,用多种分隔符组合,反对日志内容的分组,用关键词定位的形式代替固定地位的格局,同时反对格局扩大,这对人脑和计算机去解析都是一种累赘。第二个是同一个日志文件,还能呈现不同格局的内容,堆栈和失常业务日志混淆。来看一个例子,我不给任何提醒,你能在大脑里很快剖析出这个日志的构造吗?
requestParam$&trace@2150435916867358634668899ebccf&scene@test&logTime@2023-06-14 17:44:23&+skuPromiseInfo$&itemId@1234567:1&skuId@8888:1&buyerId@777:1&itemTags@,123:1,2049:1,249:1,&sellerId@6294:1&toCode@371621:1&toTownCode@371621003:1&skuBizCode@TMALL_TAOBAO:1&skuSubBizCode@TMALL_DEFAULT:1&fromCode@DZ_001:1+orderCommonInfo$&orderId@4a04c79734652f6bd7a8876379399777&orderBizCode@TMALL_TAOBAO&orderSubBizCode@TMALL_DEFAULT&toCode@371621&toTownCode@371621003&+
工具之乱
有时候甚至会呈现,同一个类,同一个办法中,两行不同的日志埋点,打进去的日志格局不一样,落的日志文件也不一样。为什么会呈现这种状况?就是因为用了不同的日志工具。要究其根源,咱们须要剖析一下不同的工具到底是在做什么。能够发现,很多工具之间的差异就是反对的参数类型不一样,有些是打印订单对象的,有些是打印消息的,有些是打印调度日志的。还有一些差异是面向不同业务场景的,比方一盘货专用工具,负卖专用工具。还有一些差别是面向不同的异样封装的,有些是打印 ExceptionA,有些是打印 ExceptionB 的。世间离奇事,莫过于此,或者只能用存在即正当去解释了。
日志分层
我始终崇奉极简的设计准则,简略意味着颠扑不破。下面提到,一套日志零碎最终的终局肯定是走向凌乱,既然这种趋势无奈防止,那么咱们在最后设计的时候就只能确保一件事,保障原始的分类尽量简略,且不重叠。其实通用的分类形式无非就两种,一种按职能程度拆分,一种按业务垂直拆分。一般来说,一级分类,应该采纳程度拆分。因为业务的边界个别是很难划清的,边界绝对含糊,职能的边界就绝对清晰稳固很多,职能其实反映的是工作流,工作流一经造成,根本不会产生太大的结构性变动。基于这种思路,我设计了如下的日志分层。
从档次上来看,其实只有三层,入口,内核,进口。入口日志只负责打印流量入口的出入参,比方 HSF,controller。进口日志负责打印所有第三方服务调用的出入参。内核日志,负责打印所有两头执行过程中的业务日志。就三层足矣,足够简略,不重不漏。另外把堆栈日志独自拎进去,堆栈相比业务日志有很大的特殊性,本文题目所指出的日志存储升高优化,也只是针对堆栈日志做的优化,这个前面再讲。
格局设计
日志的格局设计也有一些考究。首先日志的设计是面向人可读的,这个无需多言。另外也十分重要的一个点,要面向可监控的设计,这是容易被很多人漠视的一个点。基于这两个准则,说一下我在格局设计上的一些思路。首先要做维度形象。既然是面向监控,监控个别须要反对多个保护,比方行业维度,服务维度,商家维度等等,那么咱们就须要把所有的维度因子抽出来。那么这些维度理论打印的时候怎么传给 logger 呢?倡议是把他们存到 ThreadLocal 中,打的时候从上下文中取。这样做还有一个益处是,日志打印工具设计的时候就会很优雅,只须要传很少的参数。格局尽量简略,采纳约定大于配置的准则,每一个维度占据一个固定的地位,用逗号宰割。切忌设计一个大而全的模型,而后间接整个的序列化为一个 JSON 字符串。也不要被所谓的扩展性给引诱,给应用方轻易开出一个可能自定义格局的口子,即使你能轻而易举的提供这种能力。依据我的教训,这种扩展性肯定会被滥用,到最初连设计者也不晓得理论的格局到底是怎么的。当然这个须要设计者有较高的视线和远见,不过这不是难点,难的还是克服本人炫技的欲望。在内容上,尽量打印能够自解释的文本,做到见名知义。举个例子,咱们要打印退款标,退款标本来是用 1, 2, 4, 8 这种二进制位存储的,打印的时候不要间接打印存储值,翻译成一个能形容它含意的英文 code。格局示例
timeStamp|threadName logLevel loggerName|sourceAppName,flowId,traceId,sceneCode,identityCode,loginUserId,scpCode,rpcId,isYace,ip||businessCode,isSuccess||parameters||returnResult||
内容示例
2023-08-14 14:37:12.919|http-nio-7001-exec-10 INFO c.a.u.m.s.a.LogAspect|default,c04e4b7ccc2a421995308b3b33503dda,0bb6d59616183822328322237e84cc,queryOrderStatus,XIAODIAN,5000000000014,123456,0.1.1.8,null,255.255.255.255||queryOrderStatus,success||{"@type":"com.alibaba.common.model.queryorder.req.QueryOrderListReq","currentUserDTO":{"bizGroup":888,"shopIdList":[123456],"supplierIdList":[1234,100000000001,100000000002,100000000004]},"extendFields":{"@type":"java.util.HashMap"},"invokeInfoDTO":{"appName":"uop-portal","operatorId":"1110","operatorName":"account_ANXRKY8NfqFjXvQ"},"orderQueryDTO":{"extendFields":{"@type":"java.util.HashMap"},"logisTypeList":[0,1],"pageSize":20,"pageStart":1},"routeRuleParam":{"@type":"java.util.HashMap","bizGroup":199000},"rule":{"$ref":"$.routeRuleParam"}}||{"@type":"com.alibaba.common.model.ResultDTO","idempotent":false,"needRetry":false,"result":{"@type":"com.alibaba.common.model.queryorderstatus.QueryOrderStatusResp","extendFields":{"@type":"java.util.HashMap"}},"success":true}||
堆栈倒打
本文的重点来啦,这个设计就是结尾提到的奇思妙想。堆栈倒打源于我在排查另一个零碎问题过程中感触到的几个痛点,首先来看一个堆栈示例。
这么长的堆栈,这稀稀拉拉的字母,即便是天天跟它打交道的开发,置信第一眼看上去也会头皮发麻。回忆一下咱们看堆栈,真正想得到的是什么信息。所以我感触到的痛点外围有两个。第一个是,SLS(阿里云日志产品零碎) 上搜进去的日志,默认是折叠的。对于堆栈,咱们应该都晓得,传统异样堆栈的特色是,最顶层的异样,是最靠近流量入口的异样,这种异样咱们个别状况下不太关怀。最底层的异样,才是引起系列谬误的源头,咱们日常排查问题的时候,往往最关怀的是谬误源头。所以对于堆栈日志,咱们无奈通过摘要一眼看出问题出在哪行代码,必须点开,拉到最上面,看最初一个堆栈能力确定源头。我写了一个谬误示例来阐明这个问题。惯例的堆栈构造其实分两局部,我称之为,异样起因栈,和谬误堆栈。
如上,一个堆栈蕴含有三组异样,每一个 RuntimeException 是一个异样,这三个异样连起来,咱们称为一个异样起因栈。每一个 RuntimeException 外部的堆栈,咱们称为谬误堆栈。阐明一下,这两个名词是我杜撰的,没有看到有人对二者做辨别,咱们个别都统称为堆栈。读者能了解我想表白的就行,不必太纠结名词。第二个痛点是,这种堆栈存储老本太高,无效信息承载率很低。诚实说这一点可能大多数一线开发并没有太强烈的体感,但在这个降本增效的大环境下,咱们每个人应该把这点作为本人的 OKR 去践行,变被动为被动,否则在机器老本和人力老本之间,公司只好做选择题了。当初指标很明确了,那咱们就开始隔靴搔痒。外围思路有两个。针对堆栈折叠的问题,采纳堆栈倒打。倒打之后,最底层的异样放在了最下面,甚至不必点开,瞟一眼就能晓得起因。
同时咱们也反对异样起因栈层数配置化,以及谬误堆栈的层数配置化。解这个问题,实质上就是这样一个简略的算法题:倒序打印堆栈的最初 N 个元素。外围代码如下。
/**
* 递归逆向打印堆栈及 cause(即从最底层的异样开始往上打)
* @param t 原始异样
* @param causeDepth 须要递归打印的 cause 的最大深度
* @param counter 以后打印的 cause 的深度计数器 (这里必须用援用类型,如果用根本数据类型,你对计数器的批改只能对以后栈帧可见,然而这个计数器,又必须在所有栈帧中可见,所以只能用援用类型)
* @param stackDepth 每一个异样栈的打印深度
* @param sb 字符串结构器
*/
public static void recursiveReversePrintStackCause(Throwable t, int causeDepth, ForwardCounter counter, int stackDepth, StringBuilder sb){if(t == null){return;}
if (t.getCause() != null){recursiveReversePrintStackCause(t.getCause(), causeDepth, counter, stackDepth, sb);
}
if(counter.i++ < causeDepth){doPrintStack(t, stackDepth, sb);
}
}
要升高存储老本,同时也要确保信息不失真,咱们思考对堆栈行下手,把全限定类名简化为类名全打,包门路只打第一个字母,行号保留。如:c.a.u.m.s.LogAspect#log:88。外围代码如下。
public static void doPrintStack(Throwable t, int stackDepth, StringBuilder sb){StackTraceElement[] stackTraceElements = t.getStackTrace();
if(sb.lastIndexOf("\t") > -1){sb.deleteCharAt(sb.length()-1);
sb.append("Caused:");
}
sb.append(t.getClass().getName()).append(":").append(t.getMessage()).append("\n\t");
for(int i=0; i < stackDepth; ++i){if(i >= stackTraceElements.length){break;}
StackTraceElement element = stackTraceElements[i];
sb.append(reduceClassName(element.getClassName()))
.append("#")
.append(element.getMethodName())
.append(":")
.append(element.getLineNumber())
.append("\n\t");
}
}
最终的成果大略长这样。咱们随机挑了一个堆栈做比照,统计字符数量,在等同信息量的状况下,压缩比达到 88%。
思维拓展
很多文章喜爱宣扬所谓的最佳实际,在笔者看来最佳实际是个伪命题。当你在谈最佳实际的时候,你须要指明这个 ” 最 ” 是跟谁比进去的,你的适用范围是哪些,我置信没有任何一个人敢大言不惭本人的框架或计划是放之四海而皆准的。本文所提出的日志设计实际计划,是在一个典型的中台利用中落地的,三段的日志分层计划尽管足够简略,足够通用,然而最近解触了一些富客户端利用,这个计划要想迁徙,可能就得做一些本土化的革新了。他们的特点是依赖的三方服务少,大量的采纳缓存设计,这种设计的底层逻辑是,尽量使得所有逻辑能在本地客户端执行以升高分布式带来的危险和老本,这意味着,可能 99% 的日志都是外部执行逻辑打的,那咱们就得思考从另一些维度去做拆分。另外对于日志降本,本文探讨的也只是降堆栈的存储,一个零碎不可能所有日志都是堆栈,所以理论整体的日志存储老本,可能降幅不会有这么多。谈这么多,归根结底还是一句话,不要科学银弹,减肥药一类的货色,所有的技术也好,思维也好,都要因地制宜,不自量力。
作者|伏难
点击立刻收费试用云产品 开启云上实际之旅!
原文链接
本文为阿里云原创内容,未经容许不得转载。