Fork me on GitHub

阿里 | 浅谈系统实现层面稳定性保障

图片

高丙寅 淘系技术

导读

上海外滩建筑群包括古典主义风格的亚细亚大楼(1915年),英国古典式的上海总会大楼(1911年),欧洲古典折中主义的海关大楼(1925年),仿意大利文艺复兴风格的汇中饭店大楼(1906年)等,这些建筑历经百年风雨,仍巍然屹立固若堡垒,保持其原本风貌,让今人得以深切领略一个世纪以前的壮丽繁华。这一切,除得益于人为保护之外,最主要原因是建筑自身主体结构具备较高的稳定可靠性。

而相比传统建筑工程,软件工程有两个显著特点,一是具备规模化快速复制扩散的能力,二是在竣工之后依然可以被改造并保持高速进化。这也就导致了软件工程一点点的不足也可能被快速扩散、无限放大造成大规模损失,进化中既有的稳定性结构可能会被不断打破,可能导致大量的救火投入,疲于奔命,极大地消耗有效而宝贵研发资源,阻碍软件的进行甚至带来灾难性后果。所以对于任何一家上规模的软件开发的企业来说,稳定性保障都是必须面对和解决好的课题。我个人加入阿里之初是在国际支付宝核心团队长期负责金融系统稳定性保障,其后扎根淘系技术三年有余,参与了多种不同类型系统设计与稳定性建设,以及大促稳定性保障工作。对比总结下来无论电商、金融、物流、ERP型软件工程,其稳定性保障是策略是有较多共性经验的。本文主要结合金融、电商两种场景下的个人以及所在团队实践,谈谈稳定性保障的一些思路方法。

稳定性保障围绕的核心

稳定性保障涉及机房、网络、硬件部署到业务场景、交互设计,再到应用架构代码质量、流量与封网管控、攻防复盘等,是一项非常系统化的工程。而分工协作使得上述大部分工作流程化标准化,比如硬件问题、网络问题、Jvm监控等均可以由专业化团队提供统一配套方案,做为业务开发主要是利用这些工具更快的发现问题,干预度相对有限。但具体业务系统实现距离拖拽式批量生产尚需时日,最终产出实现方案差异可能很大,所以所有稳定性工作的核心还是围绕系统与代码本身来开展,如下图所示,无论是质量团队、各方管控平台,还是研发流程保障机制,最终的稳定性还是体现在系统代码上。因此,对于业务开发的来说,稳定性保障更应该着眼于系统设计实现环节的控制,如下橙色部分:

图片

而正是需求不受控、架构局限、代码扩展性不足、监控日志不统一、下游依赖不统一之类问题导致了每到大促前夕的紧张慌乱,重复的梳理、反复的打补丁,需要额外的进行压力测试,预案配置演练,消耗巨大且效果有限。所以我们需要贯穿于系统实现各环节,来制定合理的稳定性策略。系统实现核心环节与稳定性关系如下图所示:

图片

接下来着重针对上图所示各环节,做一下总结。

业务大环境与业务架构稳定性

一个人的命运必须要考虑历史进程影响,谈系统稳定性则不可以忽略大的业务环境,业务环境是指所在的部门业务现状,以及自己所在业务部门与其它业务部门的合作现状,可能发生的业务调整重组。

有时候为了满足业务上的发展目标,老板们不得不频繁的做业务调整。随之而来的是技术团队与系统架构的拥抱变化,系统拆分、合并、重建一些列工作,必然会极大地打乱既有的稳定性保障策略,所以必须要从业务环境业务架构的层面来审视并调整以跟上变化。

比如天猫技术部和淘宝技术部合并、拆分,洋淘业务下线,逛逛品牌上线,躺平业务独立,导致原有支持的统一社区运营工作台不再work,各自垂直业务线因发展需要或者被迫不得不独立出自己的运营后台管理,自己的索引,迁移兼容老数据,影响不可谓不深远。

再比如淘宝社交账号体系原来是作为淘宝内部的一个体系,仅仅面向淘宝会员服务,但随着闲鱼、躺平的独立,需要延伸出新的特性化需求,那么以现有的形态继续支持跨BU的业务发展是否是最优的方案。如果继续支持面临人力、机器资源的保障的投入,那这些与当前BU的价值与方向是否吻合?这些是我们业务上要去考虑的问题。

平台化产品思维下,所有人都想建平台并急于让更多的人接入进来,以发挥自己平台影响力,体现自身价值。而一旦业务目标调整,团队方向跟着调整,平台这一块儿不再是重点,角色反转,之前苦苦拉过来的客户被告知不再提供服务,甚至限期迁移,这对自己、对客户方的系统稳定性都极其不利。所以,越往上层的团队,建平台越审慎,先做好自己的主业而不是盲目扩展边界。一个基于短期目的,或者缺乏长远规划的所谓平台直接提供给别人用,也是不负责任的表现。

而对于一个新的业务来说,我们也需要要求产品运营不仅仅是提出一个商业需求商业模式,而是要系统化思考一个业务的心智及演变趋势。从天马行空的想法到一个可行性方案,技术人员需要发挥自身优势,和产品运营同学一起思考,将商业模式进行细化,想清楚,想透彻,往往业务上一点点的权衡或者调整对技术上的改变将会带来四两拨千斤的效果。

系统是业务的直观反馈,业务架构很大程度上决定了进一步的技术架构。如果产研团队对业务理解不深刻不透彻,只着眼于未来一年甚至几个月短期需求与利益,想到哪里做到哪里,那么技术层面就无法做好提前的布局与设计,堆砌、重复就会随之而来。大的环境背景不稳则限制了天花板,后续实现过程上无论怎么努力只能治标难以治本!所以对当前身处的业务的环境整体大图轮廓的认知,是建立全局稳定性思考的前提。

▐ 电商系统业务架构示意

图片

如果想做一个最小型的电商网站,至少也需要包含这几个业务模块,会员、商户库存、下单模块、店铺管理中心,规模稍大之后每个模块可能要支持独立的运营,可能还要考虑支持商业化接入,还可能延伸出物流、售后、资金管理等延伸模块,那么这些模块将怎么去协作,这些东西我们需要在业务大图中有一定预见。

▐ 支付系统业务架构示意

图片

比如泰国用户小A在登录速卖通(www.aliexpress.com)后,使用支付宝支付泰铢,购买了一双鞋子。那么这个支付过程至少就会涉及到上面几方。在这个过程中,速卖通是做为一个外部商户之一,支付宝要服务好千万个这样的商户。就需要独立的收单对接团队,独立出收单模块。在用户支付的过程中,还可能根据与银行之间支付服务费差异,营销活动等决策要给用户推荐的支付方式,那么就需要一个运营可干预的支付路由模块,而下游同样提供泰铢支付的泰国支付机构也有很多家,我们对于每一家的接入都是要有商务协议和技术准入,那么把金融渠道看做一个业务单元,既然有资金流动那么独立的财务核算模块就是必不可少的,包括围绕这个支付活动可以沉淀下来的会员体系,决定了在支付平台中要有用户模块。

▐ 导购场页面架构示意

图片

类似于手机淘宝中首页、我的淘宝、收藏夹这类通用的强入口,这种兵家必争之地更需要技术、产品、运营一起考虑清楚业务架构。尽可能提供通用的UI标准与接入方案,前瞻性考虑页面业务架构,能保证我们的页面复杂度不随业务方呈现线性增强趋势,业务接入可以批量化、可配置化。而如果局限于应付单次的特性化需求堆砌,几经迭代,业务逻辑越来越臃肿,每次调整将牵一发而动全身,最终难以为继。产品拥抱变化,加之人员调整频繁,新一批的产品、技术没有人能讲清楚这个页面的真实运行情况。

业务架构并不只是部门老板的运筹决策,也不是必须面对特别大的场景才需要考虑。即使我们只是一个小页面甚至小区块的产品、开发,那么对这个页面这个组件也可以有很好的业务规划,比如如下区块虽小,但做为一个产品的流量入口来说却具有决定性意义。

图片

和产品设计达成一致后可以抽象出稳定模型如下:

图片

可分为:logo区、数字标、小红点、头像区,引导文案区、图片氛围区、色彩氛围区。

基于这个稳定的区块交互架构, 我们定义统一标准协议,之后服务端就可以前瞻性规划,考虑各类营销场景、AB方案,大做文章以支持多样化业务运营需求且不会因为频繁的前后端联动修改引入稳定性问题。

先要清晰认识了解所在部门大的业务环境、背景、未来趋势。确保我们和业务方能建立业务架构上的共识,目标一致。以此为基石,保证方向正确的前提下,贴合实际去考虑系统架构与边界问题,做配套的稳定性方案,才具有更好的可行性!

业务需求把控与稳定性关系

从业务需求层面考虑稳定性,主要是做两个方面,一是业务需求过滤(价值判断),二是需求模型简化。

关于需求价值判断,尤其是对于创新型产品,产品设计同学思维很发散,天马星空idea层出不穷,这是创新源动力,是好事,但作为技术人员精力很有限,必须脚踏实地的思考可行性问题,必须要对需求方原始需求进行合理质疑,砍掉一些表面上的浮华,实际没有核心价值的伪需求,同时需求精简模型,将有限的研发精力投入到真正有业务价值的地方。

图片

做价值判断,必要时候用数据分析、数据驱动手段去证明,很可能分析结论发现整个盘子的天花板就在那里,那么一些需求就自然没有存在的理由,无价值的需求上去了除了浪费开发资源,还会带来系统复杂度的无意义升高,稳定性风险自然就升高。

有些时候业务方倾向于把一个需求方案复杂化,我们需要做模型简化,考虑是不是有必要设计如此复杂的规则。如果我们把规则简化到20%以下,是否可以满足90%以上的需求了,而剩下10%是否可以有更轻量的方式去解决。

如下针对淘友圈所在的我的淘宝入口,和业务上达成一致后,简化后的逻辑实现复杂度下降50%,那么稳定性风险也会下降50%,且对业务上带来的是同样的用户体验,体感如下:

图片

如今纷繁复杂的无线页面形态并不是越绚越好,而是需要真正找到对用户有吸引力,有价值的点。正如逍遥子和蒋凡在2020年双11所要求的,简单、好逛。很多时候,技术必须要协助产品去做减法,而业务上的去伪存真化繁为简对于系统复杂度往往带来决定性影响,做稳定性保障必须重视这一环节的把控。

业务领域模型稳定性

基于对业务的理解把控,我们拿到一个相对靠谱的业务需求,开始抽象领域模型。领域模型是从纯粹客户需求转化为技术人员可理解的语言,是对业务的高度抽象,根本目的是帮助我们理解和分析业务,以指导进一步的技术实现。

这个阶段需要与产品经理反复对焦,充分理解题意深挖出潜在逻辑(实际必须要支持的逻辑,但是局限于需求方认知在需求阶段没有提到),使用uml工具梳理出面向对象编程中的对象,以及对象之间的转化关系,需要抓住整体而不是一上来就陷入细节。

比如我们建一个面向页面资源位的投放系统,分析后可以得到如下:

▐领域模型示意

图片

▐系统领域模块划分示意

图片

领域模型分析不等于数据库ER图设计,但有了清晰准确的领域模型,再细化出ER图就变得很确定性了。

这一步是从微观层面理解业务,基于业务架构抽象出了业务的核心主体及其主体之间的协作关系,确保清晰准确的理解了需求现在要做,未来要做,未来可能做的事情有哪些,基于这个充分的理解,设计稳定可靠可扩展的模型,确保业务领域模型的稳定。

技术架构与选型稳定性

技术架构与选型这一步需要确定编程语言,数据库,系统拆分,以及你的系统之间大致是如何产生关联作用,并最终提供完整的业务能力。

技术选型主要包括:技术框架选型,数据存储索引选型,数据交互流转选型等大的模块。对配置有专门中间件团队的公司,出于效率与技术统一性考虑,代码框架一般都有一套成熟稳定的配套方案,业务团队无需在这个上面过渡投入。比如在阿里,可以在一站式研发协同平台aone上一键创建最新应用。主要精力是花在数据存储与数据交互流转技术的选择上,需要结合自身业务特点来做选择。基于业务架构与领域建模、数据规模、未来趋势、团队能力限制综合决策权衡选择合适的架构,没有最好的架构,只有最合适的架构。同时需要有对业务的预判能力,至少产品主线上可能出现的较大变化,要预留架构上的可能性。

比如系统是否有必要一开始就应该考虑到读写分离,拆分为几个系统。比如基于业务特性拆分成了读写分离的A,B两个系统,A主要做了门面抗流量,B主要异步任务,定时写入,这样避免因为瞬时异步流量过高影响生产读服务。

但因为项目人员变动较大,时间紧任务重,导致没有坚守,有些服务似乎直接写在A中更加节省工作量,妥协一下,这样A与B的职责越来越模糊,久而久之A系统中也有了较多的瞬时流量风险,风险就不可控。这就要求必须从架构上确定系统职责边界,该撕的一定要撕,如果表面总是一团和气,必然是在某些方面做了一些放弃,把所有的毛刺都按在床底下,日积月累总有一天会爆发。

而如果拆成读写分离,势必会导致有些其实都强依赖的模块,不太好界定的模块也要远程通信。那么可以达成共识,统一提供一个非client类型的公共二方包,在B中开发,同时供给A、B使用。

而如果你做为上游入口,是否考虑提供spi机制,避免后续接入N个下游,就要引入N个二方包的情况。

比如考虑一致性保障策略是用分布式事务,还是使用差错补偿机制来处理。

比如关于存储,我们是需要用nosql存储还是关系型数据库,还是按照业务特性写多份异构存储来提供更好的性能。做这一步决策就需要综合理解mysql,lindrom特性及应用场景,lindorm与redis做为索引的差异化,以及opensearch的可靠性、延时性问题。数据库设计考虑不足,导致容量瓶颈。数据库的分库分表,则应该考虑未来5至10年的业务规模。关于存储与索引技术的选型,是整个技术选型中的核心,后续将另谋篇幅详谈。

技术架构及选型直接决定了我们的系统结构上是否稳定合理,决定了在未来可预期的时间段内是否会被推到重来,是系统稳定的基础。

代码实现规范稳定性

业务架构->领域建模->技术架构与选型决定了我们整个工程的宏观可行性与各个关键节点的解决方案。接下来进入到编码环节,但这个环节来说,不同的施工队、不同的人员操作上是不一致的,可能一两个点的细节看不出主要影响,但是两边引起质变,最终会决定成败。我们需要有一套规范来保障细节的可控与标准化,来确保系统微观层面的稳定性,比如:

  • 比如包装类型与基本类型使用场景,判断对象相等方法,对象做json序列化注意事项。
  • 金额字段处理,统一规范的封装工具类。金额统一收口服务端处理。
  • 响应给上游的result到底是代表通信成功,还是业务成功。这个在注释上必要详尽说明,避免异常情况下扯皮。
  • 数据库,核心资源操作应始终对照:一锁二判三更新的基本原则。
  • 尽量避免使用|、&、异或(^)、位运算(<<),因为可读性较差,代码的可读性可维护性是除了代码本身业务价值外,技术人员对公司最大的贡献。

关于代码规范的制定,推荐《阿里巴巴Java开发手册》一书,它是阿里内部Java工程师所遵循的开发规范,涵盖编程规约、单元测试规约、异常日志规约、MySQL规约、工程规约、安全规约等,这是近万名阿里Java开发人员经验总结,并经历了多次大规模一线实战检验及完善,具备较强的参考意义。

代码通用模块稳定性

结合自身系统特点,理清楚变与不变。把可以反复使用的部分、易出错的部分以及系统核心引擎抽象出来,做为系统不变的部分。把伴随着业务的变化或者新业务方接入而不断调整的部分提取出来,做为系统中可变的部分。

对于不变的部分,运用设计模式及常用套路将其固化下来形成公共模块,降低类似功能重复编写带来的风险,提高增量业务迭代效率。这部分代码投入核心精力让它像工具类一样稳定可靠,之后反复运行。这部分的抽象决定了系统的核心代码层次,保障大楼的上相似的模块统一稳定,有统一的管控手段。

对于变的部分,提供扩展点。这部分是会随着业务的变化而不断迭代,同时要考虑让变化的部分具备隔离性。即当改动一个子业务需求时,尽可能从从架构上限定住它的影响范围。

追求高内聚,低耦合,满足开闭原则易扩展易维护的代码层次结构。

当然这里要避免一个误区,即滥用设计模式,或者为了模式而模式。比如在一个小小的项目里本来简单一个方法调用就能实现,确要过度套用设计模式去编码,折腾出来好几层,开发成本高且给代码的维护带来了困难。私下练手可以。但在工程层面,则要更多的考虑实用价值,实际一定要结合场景需要。

下面简要列举几个常见的点:

▐统一对外服务层异常兜底

对于多数应用来说为上游提供hsf是其主要职责。标准是当service层抛异常,我们需要自己处理掉异常,以Result结果中的结果码的形式与外界通信,而不是直接抛运行时异常给业务方。那么我们可以抽象出AOP层,对Service层的异常统一捕获,返回兜底错误码给上游。

▐统一抽象摘要日志模块

方法的核心出入参,是我们监控的关键。但是穿插在业务代码中打印,总是容易遗漏而且侵入性很强。那么可以抽象摘要日志注解模块,无侵入的打印方法的摘要日志。

▐统一下游依赖模块

在淘系,我们做导购型产品,基本绕不开淘系3C,即UIC(用户中心)、TC(交易中心)、IC(商品中心),可能还会有一个SC(店铺中心),而这些中心又因为历史的原因提供了多套对接查询方法。曾经看到一个系统中对IC的直接依赖有10+处之多,同样是查询商品对象,但由于不同的开发人员依赖了不同的方法。

那么每当大促链路梳理的时候,或者IC包做升级的时候,就需要梳理回归多个入口,给系统的链路梳理带来了极大的不确定性。

正确的做法是我们对于同一个下游入口,包装出唯一的代理类,唯一的方法,统一维护、监控,反复使用。这里的下游不仅仅指业务系统,同时包括对于一些中间件的依赖,比如:针对hbase、redis、ldb、opensearch、odps的访问封装。

▐Java线程池使用

单个应用对异步线程的管理,应该有统一的类收口,使用者只需要传递线程池名及所需要的变量即可。

这样通过统一的监控配置一目了然就能看到整个应用对异步线程池的使用管理情况,快速诊断出是哪里的线程池使用不合理导致的系统线程数飙高报警,也便于后续的交接维护。这个类就可以完成对线程池的所有幻想,屏蔽掉对线程池工具类的直接访问,至少包括这些类的行为:

  1. 创建指定线程池
  2. 并发执行/单个执行
  3. 同步执行/异步执行

统一的线程池管理工具类示例:

/**
 * 说明:通用线程池工具类
 */
public class CommonExecutorManager {

    public static final String POOL_DEFAULT = "POOL_DEFAULT";

    /**
     * 线程池map
     */
    private Map<String, ExecutorService> threadPoolMap = null;

    /**
     * 默认线程池
     */
    private ExecutorService defaultExecutor = null;

    @PostConstruct
    public void init() {
        threadPoolMap = new HashMap<>(2);

        /**
         * 默认线程池
         */
        ThreadFactory defFactory = new com.google.common.util.concurrent.ThreadFactoryBuilder().setNameFormat(
            "AsyncManager-default-%d").build();
        this.defaultExecutor = new ThreadPoolExecutor(8, 16,
            100L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(1024), defFactory,
            new ThreadPoolExecutor.DiscardPolicy());
        threadPoolMap.put(POOL_DEFAULT, defaultExecutor);
    }

    @PreDestroy
    public void close() {
        if (defaultExecutor != null) {
            defaultExecutor.shutdown();
        }
        for (ExecutorService pool : threadPoolMap.values()) {
            pool.shutdown();
        }
    }

    /**
     * 异步执行supplier
     *
     * @param poolName 线程池
     * @param supplier supplier
     * @param <T>      结果类型
     * @return
     */
    public <T> CompletableFuture<T> supplyAsync(String poolName, Supplier<T> supplier) {
        if (poolName == null) {
            poolName = POOL_DEFAULT;
        }
        ExecutorService executorService = threadPoolMap.getOrDefault(poolName, defaultExecutor);
        return CompletableFuture.supplyAsync(supplier, executorService);
    }

    /**
     * 并发执行supplier, 并等待结束
     *
     * @param poolName     线程池
     * @param supplierList supplier list
     * @param <T>          结果类型
     * @return 结果列表
     */
    public <T> List<T> supplyListSync(String poolName, List<Supplier<T>> supplierList) {
        if (poolName == null) {
            poolName = POOL_DEFAULT;
        }
        ExecutorService executorService = threadPoolMap.getOrDefault(poolName, defaultExecutor);
        List<CompletableFuture<T>> futures = supplierList.stream()
            .map(supplier -> CompletableFuture
                .supplyAsync(supplier, executorService))
            .collect(Collectors.toList());
        return futures.stream()
            .map(CompletableFuture::join)
            .collect(Collectors.toList());
    }
    /**
     * 查询线程池
     *
     * @param poolName 线程池名称
     * @return
     */
    public ExecutorService getExecutorService(String poolName) {
        return threadPoolMap.getOrDefault(poolName, defaultExecutor);
    }
    /**
     * 创建线程池
     * @param corePoolSize 核心线程数
     * @param maxSize  最大线程数
     * @param threadName 线程名称
     * @param daemon
     * @return
     */
    public static ExecutorService createThreadPool(int corePoolSize, int maxSize, String threadName, boolean daemon) {

        ExecutorService executorService = new ThreadPoolExecutor(corePoolSize, maxSize, 5, TimeUnit.MINUTES, new
            LinkedBlockingQueue<>(), new com.google.common.util.concurrent.ThreadFactoryBuilder().setNameFormat(
            threadName + "-%d").setDaemon(daemon)
            .build());

        return executorService;
    }

    /**
     * @param nThreads
     * @param threadName
     * @return
     */
    public static ScheduledThreadPoolExecutor createScheduledPool(int nThreads, String threadName) {

        return new ScheduledThreadPoolExecutor(nThreads,
            new com.google.common.util.concurrent.ThreadFactoryBuilder().setNameFormat(threadName + "-%d").build());
    }

    public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
            60L, TimeUnit.SECONDS,
            new SynchronousQueue<Runnable>(),
            threadFactory);
    }
}

▐定时任务使用

同管理线程池一样,单个应用对定时任务的使用,是可以有通用的部分抽象出来,比如对于网格任务中的根任务与子任务的识别。具体的定时任务实例只需要聚焦在子任务的特性读取与单条任务的计算处理上。

这样通过统一的监控配置一目了然就能看到整个应用定时任务执行的情况,快速诊断出是哪个定时任务的运行导致系统资源报警。统一的job抽象类示例:

public abstract class AbstractMapJob extends MapJobProcessor {

    @Override
    public ProcessResult process(JobContext context) throws Exception {
        try {
            String taskName = context.getTaskName();
            Object task = context.getTask();

            if (isRootTask(context)) {
                //生成子任务
                List<IndexChildTask> childTasks = generateChildTasks(MsgSwitch.childTaskCount, context);
                return map(childTasks, getChildTaskName());
            } else if (getChildTaskName().equals(taskName)) {
                try {
                    //子任务处理
                    handleChildTask(task);
                    return new ProcessResult(true);
                } catch (Throwable t) {
                    log.error("handleChildTask error.", t);
                    return new ProcessResult(false);
                }
            }
        } catch (Exception e) {
            log.error("process error", e);
        }
        return new ProcessResult(true);
    }

    /**
     * 根据总记录数拆分生成子任务
     * @param taskCount 要拆分的子任务个数
     * @return
     * @throws Exception
     */
    private List<IndexChildTask> generateChildTasks(int taskCount, JobContext context) throws Exception {
        long totalCount = getTotalCount(context);
        log.info("total count: {}", totalCount);
        if (totalCount == 0) {
            return Collections.emptyList();
        }
        long taskSize = (long)Math.ceil(totalCount * 1.0 / taskCount);
        List<IndexChildTask> taskList = new ArrayList<>();
        for (long i = 0; i < taskCount; i++) {
            long start = i * taskSize;
            IndexChildTask childTask;
            if (i == taskCount - 1) {
                childTask = new IndexChildTask(start, totalCount - (i * taskSize));
            } else {
                childTask = new IndexChildTask(start, taskSize);
            }
            taskList.add(childTask);
        }
        return taskList;
    }

    /**
     * 获取要处理的总记录数
     * @return
     * @throws Exception
     */
    public abstract long getTotalCount(JobContext context) throws Exception;

    /**
     * 处理子任务
     * @param task
     */
    public abstract void handleChildTask(Object task);

    /**
     * 获取子任务名称
     * @return
     */
    public abstract String getChildTaskName();
}

▐消息订阅处理

将消息接收的注册、序列化为业务类型,异常处理,监控日志做统一的封装,收口,使得消息的消费监控变得简单易于执行,同时便于后续的开发维护,示例类:


public abstract class MetaqCommonConsumer<T> implements MessageListenerConcurrently {

    private MetaPushConsumer consumer;

    private String topic;
    private String tag;
    private String consumerGroup;

    /**
     * 具体的消息实体类型
     */
    protected Class messageObjClass;

    public MetaqCommonConsumer() {
        // 获取父类带泛型的类型
        Type genericSuperclassType = getClass().getGenericSuperclass();
        ParameterizedType parameterizedType = (ParameterizedType)genericSuperclassType;
        Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();
        messageObjClass = (Class)actualTypeArguments[0];
    }

    @Override
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext context) {
        if (list == null || list.isEmpty()) {
            return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
        }
        for (MessageExt msg : list) {
            return consumeSingleMsg(msg, context);
        }

        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    }

    /**
     * 消费消息
     *
     * @param msg
     * @return
     */
    private ConsumeConcurrentlyStatus consumeSingleMsg(MessageExt msg, ConsumeConcurrentlyContext context) {
        if (msg == null) {
            return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
        }
        try {
            String bodyJsonStr = new String(msg.getBody(), "UTF-8");
            // 转换为实际类型
            T messageObj = (T)JSON.parseObject(bodyJsonStr, messageObjClass);
            return process(messageObj, msg, context);
        } catch (Exception e) {
            log.error("consumeMessage error, {}", JSON.toJSONString(msg), e);
        }
        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    }

    /**
     * 消息处理方法
     *
     * @param messageObj 转换为具体类型的消息obj
     * @param messageExt 完整的消息体
     * @return
     */
    protected abstract ConsumeConcurrentlyStatus process(T messageObj, MessageExt messageExt,
                                                         ConsumeConcurrentlyContext context);

    /**
     * 初始化
     */
    public void start() {
        try {
            consumer = new MetaPushConsumer(consumerGroup);

            consumer.subscribe(topic, tag);
            consumer.registerMessageListener(this);

            consumer.start();
            log.info("start success consumerGroup: {}, topic: {}, tag: {}", consumerGroup, topic, tag);
        } catch (Exception e) {
            log.error("start consumer error consumerGroup: {}, topic: {}, tag: {} , errorMsg: {}", consumerGroup, topic,
                tag, e.getMessage());
        }
    }

    public void setTopic(String topic) {
        this.topic = topic;
    }

    public void setTag(String tag) {
        this.tag = tag;
    }

    public void setConsumerGroup(String consumerGroup) {
        this.consumerGroup = consumerGroup;
    }

▐报文网关交互

比如报文网关系统无论和外部怎样的通信区别变化的部分在于报文格式不同需要有不同的行处理逻辑,不变的是通过http进行request、response的通信逻辑,以及对json、xml格式报文的解析方式。

▐文件解析模块抽取

而文件网关系统则一定是现将文件转换成流读取到内存,再转换成一行,针对这一行做特性化处理,最后关闭流操作,那么我们可以把文件处理的的逻辑抽成模板方法,后续来一种新的文件,只需要实现模板方法,专注处理某一行的业务逻辑就可以了。

通过通用模块的抽取,我们可以极大的规避同一类的风险,也为后续的统一发现能力的建设提供了基础。

发现能力基础建设

这里主要强调为了做发现能力建设,系统需要满足怎样的设计更合理,围绕代码实现谈谈建设思路。

在前述架构合理、分层得当、代码规范到位、兜底保护完善的情况下确保我们项目可以顺利验收上线运行。

但在运行过程中都会受到各种因素的影响,难免还是会出问题,出问题并不可怕,关键是能否尽可能早的在第一时间发现问题并解决问题,而发现问题往往比解决问题更具有挑战性。试想如果不该打印日志的地方打印了日志,不该配置报警的地方配置报警,错误码粒度杂乱无法区分, 那么真正的问题就不能被及时发现。

对于一个直面无线app的服务端应用,系统层面很容易得到如下通用视角:

图片

抽象来看系统层面发现能力建设主要围绕以下4个要素:

图片

绝大部分的稳定工作都是围绕上述四个要素在投入,梳异常链路、映射错误码、加日志、加监控。针对这些问题,我们需要有统一的、尽可能低成本低侵入的解决方案和标准,在日常的研发过程中就尽量随时准备好。

▐统一错误码标准

需要建立错误码标准,分外部错误码、内部错误码,建立错误码统一维护策略。

需要上游业务感知,或者方便上下游之间定位问题,可以定义外部错误码,这部分错误码的变化需要非常谨慎,特别是上游已有引用已有理解的情况下。而内部错误码是指系统的各个层次之间内部执行异常得到的错误码,通常不对外输出,主要用于业务场景监控与异常定位。

错误码的设计要结合自身业务特点,制定适合自身业务的错误码。比如银行的结果码细分品类繁多,不得不用数字码来替代,但导购应用需要前端感知的结果码很有限,这个时候可读性占据主导地位,就不建议输出使用数字编码,这样的格式机器可读,但人不可读。

图片

▐统一异常处理机制

面向异常编程,定义统一业务异常类,通过业务错误码进行异常分级分类。当抛出原生运行时必须打印完整堆栈。当抛出系统业务异常,对于业务预期之中的则权衡是否需要堆栈信息。

异常分类的方式有两种,一种是通过继承设计不同的异常类来做异常区分,另一种是通过在异常中增加异常结果码。实践上,推荐使用异常结果码这种比较灵活和方便的方式。

▐统一日志输出规范

日志是我们发现、定位问题的基础,几乎绝大部分的稳定性工作都是在围绕着日志进行。

基本原则

  1. 鹰眼id、压测标、ip、时间、线程等业务无关的必要信息,由日志框架层统一解决。
  2. 日志文件功能分级,在默认application.log之外至少分出方法xflush摘要日志文件、异常日志文件。
  3. 统一监控日志格式,针对xflush日志文件,定义行列结构
  4. 谨慎打印日志,不多打,也不少打,注意日志打爆磁盘的问题。

设计示例

格式化摘要日志:

日期|eagleyeId|ip|压测标识位|日志级别|预留1|预留2|线程号|(层级^场景)(类名^方法名^业务成功表示位^结果码列^异常根原因^耗时)(监控属性1^监控属性2^监控属性3)(业务属性1^业务属性2^业务属性N)

其中监控属性区: 存放比如用户是否种子用户,返回动态条数等,可能用于监控分析的字段,主要用于监控。

**业务属性区:**存放比如userId,动态id,红包id之类的字段,主要用于异常定位。

灵活而业务无侵入的使用方式:

/****
     * 构造开圈页面入口的动态消息
     * @param friendPageDTO
     * @return
     */
    @Digest(logger = "COMMON-XFLUSH",layer ="manager", success ="!ret?.isEmpty()",monitor = {"ret?.size()"} ,attrs= {
        "userId"}
    )
    public List<OpenTipsVO> getTaoMomentsOpenPageTips(Long userId, Optional<FriendPageDTO>friendPageDTO) {
       //do something
    }

输出效果:

2020-12-22 16:40:44.618|0b13045816086264438145928e77fb|11.23.98.0||WARN |HSFBizProcessor-DEFAULT-8-thread-47|(manager^-)(TaoMomentsEnterTipsManager^getTaoMomentsOpenPageTips^Y^-^-^86ms)(2^-^-^-^-)(2200782267981)

▐ 统一贴合业务的监控机制

监控是手段和目标,需要在错误码、异常、日志三个要素的基础之上基于业务特点细化到每一段核心代码逻辑,能够及时发现预期之外的运行逻辑,报警精准程度则直接决定了线上运维成本。根据适用场景可以把分为通知型与大盘型,通知型是指直接通过某种通讯工具报警触达至接收者,而大盘型是指用户定期主动浏览,通过实时或者T+1(h+1)报表能够进行业务统揽以发现关键问题。

通用监控告警层

这里抛开硬件及基础设施监控先不谈,仅看系统代码层面的情况。

通用监控是指具有业务无关性,只要是无线服务端应用都会需要的监控。

比如mtop层方法执行超过500ms、错误码环比大涨、异常量大涨、出现未知异常。这类异常应该由统一解决方案,无需按照业务模块重复建设。

特定业务场景告警层

结合系统所承载的业务本身,对关键链路做特定监控,比如淘友圈特定业务场景监控:

图片

建立业务系统统一大盘

针对一个系统,我们会有aop监控层、各个子业务场景监控点。随着功能的叠加监控点会越来越多。这些监控点会在具体场景出问题时报警给我们。但假设就在某一个时刻,想知道系统各业务是否都运行正常,就需要有全局视角的业务大盘。在这个大盘上可以覆盖系统的主要业务场景,承担系统晴雨表的效果,关键时候可以一目了然、心中有数。

恢复能力基础建设

这里主要强调为了做发现能力建设,系统需要满足怎样的设计更合理。

当通过发现能力发现问题后,进一步面临的是如何恢复的问题,一般有如下几种手段:

图片

▐业务降级

当发现服务接口超时,定位到是某一个下游依赖所致。且判断该依赖并非产品核心流程,此时需要一键关闭掉对该服务的调用,进而保障主流程能够正常工作。要求我们在编码的时候能够梳理强弱依赖,对弱依赖增加必要的降级开关,并形成预案。

▐重试补偿与人工介入

重试补偿分自动重试补偿和人工补偿两种情形。

自动重试如消息重试,线程内循环重试,一般是因为机器进程中断,网络延时等原因导致的差错场景,这类场景经过系统自动重试机制是可以完成的。

而人工补偿主要面对人为引起的脏数据,或者依赖外部机构带来的不可控因素。

比如一个客户的支付单据,因为国外银行端的接口bug,导致返回了不确定的流水单号或者金额数据或者指定导致校验不通过,因而支付状态未推进。这种情况可能必须要通过客服手段去联系银行人员,因为确认后我们需要手工触发单据到支付完成状态。如果偶尔几笔可以由开发人员通过数据订正手段完成,如果这样的情况时有发生,那么就需要考虑是否开发差错恢复功能,让业务运营可以在限定条件下直接介入推进单据状态,完成状态补偿。

▐自动切换

这里想说的其实类似于分布式服务发现Zookeeper的自动探测能力,zk通过探测容器是否存活决定是否将容器从可用列表中移除或增加。而对于业务接口我们有时也需要这种探测能力,通过探测接口的可用性,决定是否启用。尤其在有多个下游服务竞争,且稳定性得不到充分保障的情况下诉求较为强烈,比如用户钱包有N个银行卡可用,当某个银行接口出现故障的时候,我们应该具备自动识别的能力。比如超过一定的错误阀值及失败率,自动将这个银行接口切换为不可用将支付方式置灰并给用户以文案提示,每隔一定的时间再做自动的探测尝试,发现成功后再自动切换为可用状态。

极限兜底保护稳定性

汽车的安全气囊,虽然阻止不了车祸,但很可能会挽救一次生命。兜底保护相当于一些安全手段,比如紧急情况事故无可避免,但是我们可以把损失降低到最小,比如无论在任何情况下,当前端或者服务端出现任何异常的情况下,不允许用户看到诸如500,404,空白页之类的页面。这需要全面的异常分支分析+与产品人员的充分对焦+充分的兜底场景测试。

  1. 单机qps限流2)norya自适应系统load限流3)消息接收处理线程数控制,就是无论什么时候,要保障你的系统是活着的,研发人员必须建立这样的认识。

使用meta的线程数来进行削峰平谷,保护应用。

图片

资金操作稳定性


资金操作稳定性保障即资损防控

1)如何定义资损?

广义的资损应该包括资金流转活动中,资金未按照业务规则预期流动的情况,导致业务参与方中的任何一方或多方遭受了资金损失。比如红包活动中,如果因为技术故障给用户少发了5毛或者多发了5毛,无论谁吃了亏,沾了光,只要是不符合预期的,均定义为资损,都应该界定为资损。

2)资损也可以归属于稳定性范畴,为什么要单独拎出来?

个人理解资损是稳定性问题中危害最大波及面最广的一种,尤其是对金融系统来说。

我曾经在支付宝金融核心组参与研发工作4年,看过公司内外不少案例。深刻体会到资金风险可能给业务及公司带来的灾难性后果,金融软件要求我们在所有的地方都做到最好,容不得半点损失,多一分钱少一分钱都要复盘追责。彼时资损案例的发生直接与绩效直接挂钩,每一行代码都需要被自动化脚本覆盖到,花5分钟写的代码,可能需要花2小时来编写、运行测试脚本,要求极其严苛,但在金融系统中,这一切都是值得的。

3)电商团队资损防控看法

近两年以来,淘系对于资损重视程度逐渐收紧,不少防控机制从蚂蚁引入。因为虽然电商不属于金融系统,但做为一个经济体,任何细小的波动都可能给品牌形象带来巨大破坏和影响。但无论怎样,其要求的严苛程度与金融软件是不一样的,不能所有的问题都一刀切,不断叠加的机制、流程会带来新的问题。尤其针对电商创新型业务,是需要结合具体业务场景具体分析,在稳定性保障与业务创新迭代效率之间找到一个平衡点,定义最合适粒度的资损防控策略。

持续重构与稳定性的关系


在业务先赢的时代背景下,特定时候不得不做妥协,不得不上临时方案,这样导致即使原本优雅稳定的系统实现,依然会被一点点扭曲,形成越来越多的技术债。这些技术债会成为越来越大的绊脚石,除了会障碍业务快速发展,更会直接带来额外稳定性风险和巨大的人力开销。我们必须要做持续的重构来偿还,下面举几个示例:

▐无线页面接口爆炸问题

比如下图所示的某产品首页,一旦进去首页会同时打开8个接口,而这种暴涨增长的接口,势必给后续的开发迭代及稳定性工作带来较大挑战,同时也影响了低端机用户体验。你可能说这一个业务很复杂,所以不得不使用8个。但我们抓取手淘首页接口,发现那么复杂的页面结构,只要一个接口搞定了,因为统一的协议标准定义的科学、通用。

而抽象出这样的接口,是需要对产品形态及未来趋势、前后端研发协作机制有充分考虑。

图片

▐系统对商品服务的依赖问题

比如对IC、主搜索依赖,全站点下对商品的状态、价格统一处理的问题,这些问题在一两个功能下都不需考虑太多问题。但功能点越来越多,问题就会逐步出现,就需要进行统一收口,把复杂易出错的问题集中在一个地方,集中精力解决一次。

▐feeds流加载逻辑问题:

做为一个朋友圈形态的电商内容场的页面,其feeds块类型包括商品、红包、游戏、玩法各种类型,需要从商品、营销、关系、互动获取各种类型数据来透出,那么怎样去组织代码结构,才能在后续尽量小的侵入去接入新品类,需要斟酌推敲。

▐ 我的淘宝动态入口读写模式问题:

当你进入我的淘宝,过去的做法是直接pull的形式,拉取你的好友的动态,为了保护我淘页面的性能,超时设置为100ms。但随着动态类型越来越多,超时的情况时有发生,这个时候就需要基于稳定性与可持续扩展层,提出新的方案并改进既有代码。

诸如上述所列,有些重构可能和短期的业务目标无直接的联系,尤其是产品运营同学不能直接感知的情况,很容易被忽略,得不到重视。但如果不做,坑就会在那里越积越多,引发线上的故障额概率就会越来越大。

可压测性能力建设

这里主要强调为了支持可压测,系统需要满足怎样的设计更合理。

导购型业务流量大是其最主要特点,当我们开发新功能的时候,很容易因为节奏的问题而急于上线,完全不考虑这个代码在双11的情况下是否OK。导致到了压测的时候才发现这段逻辑没有识别压测流量,可能会导致将压测数据写入到正式库中。理想的状况是在项目一开始,就考虑对压测的支持方案,预留扩展点,项目时间紧可以理解,但是一定要能提前布局。否则会因为突击压测能力改造导致代码侵入极强,时间上比较被动,最后临近封网,有些流程其实没有经过压测,也只好硬着头皮上线,具有较大不确定性。

服务端CI能力建设

这里主要强调服务端CI能力,依然是我们系统工程中需要代码实现的部分。

对于服务端开发来说,自动化回归主要关注单元测试和接口集成测试。单元测试关注单个类单个方法的逻辑正确性。接口集成测试一般关注单个系统单个service方法的逻辑正确性。具体这个层面做到什么程度需要依赖业务场景而定,比如高风险、模型稳定的核心系统就必须需要严格要求自动化覆盖率,而创新型快速迭代的新业务讲究快跑覆盖核心流程比较合适。

比如支付宝多数系统都与资金相关稳定性要求极高,要求每次代码变更的自动化行覆盖率甚至分支覆盖率不能下降,且手工测试不计入内,似乎为了规避风险无论怎样严苛的卡控都是一种政治正确。曾有人做过局部统计,发现写1行业务代码需要5行测试代码来覆盖,改一行代码需要重跑整个系统的数百个用例可能耗时一天, 以保障其业务系统高度稳定可用,但弊端是降低了生产效率。

而电商内容业务创新型团队,业务变化极快。产品经理idea天马星空,更看重的是业务快速上线快速试错的能力,我们需要两害相权取其轻做决策。针对这样情况建议梳理出业务核心主流程,针对主流程建立自动化回归用例持续运行。

长远来看,服务端CI自动化能力是保障系统核心模块稳定性不可获取的手段,但需要视场景、合理规划。忌不切合和业务实际的一刀切要求。

上下游SLA稳定性

这里主要强调系统之间的SLA,自身做的再好,合作方上下游没有规范好是不够的,依然隐患重重。这里上下游是指使用你系统服务的前端、上游服务端,以及被你调用的下游服务。

SLA只是一种约定,目的是确保接口提供方和使用方能够对接口有一致的理解,能够更好的保障业务活动进行,同时在发生线上故障必须要进行定责的时候,是一份参照标准,一份免责声明。个人认为可以不局限于形式,但一定要可追溯,文档或者清晰的接口注释都具备同样效力。与上下游之间做好SLA协议签订,在金融级系统几乎是必须要做的事情。

但在淘系这边业务开发的过程中 ,发现有时候两个团队的开发一碰头,或者口头约定或者钉钉上沟通,就开始调用接口了。也不管result.success到底代表是业务执行成功,业务受理成功,还是不确定。或者当时沟通清楚了,但是也没有关键性文档沉淀下来,就发上线了,潜在风险很大。

比如A依赖B以check是否可以给B用户发红包,那就必须对B的入参、出参,结果码有100%的理解,且有明确文档落下来。


/**
*用户资质检查服务
*/
public interface CheckService {
    Result check(QueryParam queryParam, UserInfo userInfo);
}

对比一下,如下Result,就很清晰明了,让人一看就很有确定性:

/**
 * 开放API2.0_活动投放开放API
 */
public interface DeliveryOpenService {
    /**
     * 投放活动-支持单资源位单实例
     * 1)result.isSuccess=ture代表业务上数据写入成功,此时data为工匠平台的活动id,建议业务方做存储,便于后续问题的定位排查
     * 2)result.isSuccess=false情况下,errorCode会有相信错误信息,业务方需做监控并采取必要的处理
     * @param singleTypeActivityRequest 活动对象
     * @return <activityId,#activityId#>
     */
    OpenResult<Map<String,String>> createActivity(SingleTypeActivityRequest singleTypeActivityRequest);

总结

上医治未病,中医治欲病,下医治已病。本文大致围绕系统设计实现各环节,分析了各环节对稳定性保障的影响,同时介绍了对应的一些套路和经验。着眼点主要在于未病、欲病阶段的思考投入,尽可能把一些问题的解决和规避前置均摊到业务需求受理把控、系统分析设计与编码实现阶段,同时持续改进健全,最终达到提高全局稳定性保障工作效能的目的。

淘系技术部-内容社交平台

淘宝内容社交平台,致力于通过构建高流量、高并发的分布式系统架构支撑业务先赢,通过有挑战性的技术攻关提高技术先进性,通过数据与实验驱动提升业务创新迭代效率,基于社交的应用场景将成为淘宝业务发展的新引擎。欢迎热爱技术,对业务有好奇心,有合作精神的同学一起工作、成长,简历可投邮箱:

changkun.hck@alibaba-inc.commailto:changkun.hck@alibaba-inc.com

bingyin.gby@alibaba-inc.commailto:bingyin.gby@alibaba-inc.com


本文地址:https://www.6aiq.com/article/1614358364962
本文版权归作者和AIQ共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出