拒绝叠叠乐!我用设计模式重构核心项目

3,592 阅读30分钟

本文正在参加「金石计划」

前言

最近两个月在忙项目的同时接到了一个重构核心项目的活,又是熟悉的要求,又快又好,主打一个我全都要。时间紧张,按理说叠叠乐是最优解,但是我很倔强,我不想玩叠叠乐。虽说堆X山的我干了也不少,留下了不少有气味的项目,但是我想要做一些不一样的,让人眼前一亮的设计。就像题目说的,我重点使用了模板模式和单例模式来做计算服务的重构,也用到了大量的优化手段来提升计算的效率,确保从定时计算升级到实时计算。

前情提要,X山气味很重,作者吐槽凶猛,这不是开往幼儿园的车,再重复一次,这不是开往幼儿园的车。各位没来得及下车的乘客无论坐没坐好,我只说一次,车门已锁死,发车啦!

恶龙待机中

浅说一下项目背景

订单交付是公司的核心项目,其目的是通过市场代码名称维度的资源,进行订单交期、料号、库位的自动运算,从而减少供应统筹人工维护订单信息的工时,缩短订单周期,提高交付质量。项目一期历时大半年的开发和完善,前后投入五名后端和三名前端,最终于半年前正式稳定运行。项目二期于今年二月初进行开发,除开二期的需求迭代之外,需要将原来一小时一次的定时计算改成实时计算,需要从底层更换架构来适应实时计算。

项目一期分成三个服务,后端、计算、数据。数据服务一期是主要做定时批量读取公司数仓数据做二次清洗,保存到数据库,主要是做本地表,提高查询速度。

后端服务负责与前端交互,页面上增删改查导入导出和一些简单的计算逻辑。困难点在于查询接口的优化,因为涉及大量实时查询和大Sql,数据量多是百万级,动不动联查几张十几张表,必要时还需要通过多数据源从ERP的Oracle数据库补充数据。一期我前期参与了后端服务的开发,负责页面的增删改查导入导出之类的活,没有参与计算的开发,因为当时我很快就被抽出去干另一个项目了。

计算服务一期经历了三个开发,风格各不相同吧,从现在代码来看,可以说是叠叠乐了,谁都不敢动之前的代码,改得很憋屈。计算是整个订单交付项目的核心,本质就是用户在商城前端创建订单,传递到ERP,然后订单交付项目通过ERP等多个系统的基础数据,组装订单的详细信息,同时计算约定交期、料号、库位等信息提供给相关业务人员,缩短订单交付周期。

项目二期是由我这个不太懂业务的小伙子作为项目负责人,带着一个厉害的小伙伴进行计算服务的重构,还有个和我一样不懂业务的小伙子负责后端服务的二期需求迭代开发。喔,对了,我还负责后端服务的接口性能优化,详见查询接口性能优化实录,讲点新手也能用的 - 掘金 (juejin.cn),可谓步履维艰。

梳理一二期架构

一期架构就是经典微服务架构,拆分了后端、计算和数据三个微服务各司其职,数据上采用定时全量数据运算,每小时运行一次,一次运行二十分钟。

二期因为实时计算的需要,引入了Flink做数据的支持,也就变向干掉了原来的数据服务,同时将数据迁移到了分布式数据库TiDB,方便扩容。因为订单交付问题在于大数据量,项目本身对内没有什么高并发,项目需要的基础数据来源于多个系统,其中ERP的表数据量通常是千百万级,大部分还是基表中的基表,需要做大量的联查,一联查数据量就更大了。

整个数据处理流程是,OGG采集Oracle多个基表推送消息到Kafka,利用Flink消费消息,清洗掉部分无用的字段和数据,小部分能做宽表的做宽表,数据处理完毕后存放到TiDB中。限于公司Oracle数据库的数据质量太差,很遗憾没有将计算做到Flink上.。难点主要在于开发时间短,这个我都不想吐槽了,看过我历史文章的兄弟们肯定能了解我的开发速度只能说异于常人了,麻了。除开时间的问题,主要是计算业务代码前期进行梳理时发现过于复杂,全程10个计算小模块,中间涉及大量的数据库实时查询以及修改库的操作,限于我对业务的了解尚浅,不能大刀阔斧地修改计算流程。因此我和跟我一起重构的小伙伴,判断该计算不太适合放到Flink直接计算,而是采用spring boot实时消费Kafka消息来做实时。因此我们的重心就变成,如何将二十分钟的计算流程优化到尽量短的时间。

如何快速上手老项目

一般的小项目,看看页面和需求文档,读读代码跑一下程序差不多就能理解个大概。但是面对多模块上万行代码的究极恶龙,这种方法无疑是极其费时费力的,特别是在你的领导催你干活,定的工期紧得像冬天雪地电线杆舌头一样的时候。我能理解海绵可以挤水,但是有的人想把海绵变成水,就踏马离谱。说一千道一万,经历过太多的我,浑身都是性能优化和提效强化的样子,见的那啥多了,自然会总结一些套路复用。

老项目特别是大点的项目都有些个大毛病,比如轮到你背锅的时候可能都是N代目了,文档和各种记录往往还是最初那个人留下的,后面接手的大多得过且过,这时候你要问我的建议?我的建议是当然是加入他们啊,你在想啥呢,有时候项目能跑,可能恰好就是有个BUG打通了任督二脉,万一让你改了岂不坏事了。还有的就是代码质量问题了,几轮开发下来,没有统一的规范很容易变成了各写各的,后来的不敢改前任的代码,只能搁这玩叠叠乐。说实话这质量都不用想了,我回忆起了一些不好的东西,呕。

我虽然有时候会生气,有些同事们的代码属于典型的不可维护,甚至会出现明显的低级错误,这种怒气通常出现在合作开发以及做优化的时候。但是从我个人角度来讲,我能够理解大家,毕竟工期实在是太离谱了,三天一个看板项目从零开始的后端开发,甚至没有数据库设计和需求沟通,看过我之前文章设计方案-大数据量查询接口优化 - 掘金 (juejin.cn)的读者,肯定体会到这一份艰辛。在这种速度下开发出的代码质量不高,确实情有可原,同时我也是尽可能地从我开发者的方向,为大家持续产出高质量的组件,提供类似开发框架的体验,减少大家额外的工作量。

(⊙﹏⊙),跑题了,拉回来,吐槽多了些,来劲了。上手老项目的第一要点肯定是通过原型和数据库来对照理解数据流,只梳理核心页面关联的关键表,别的不用关注。

关键表的重要字段一定要搞清意思和来源,需要了解从页面哪里更新或者计算的逻辑。

如果只是负责某一块,那就重点了解那一块,其他模块大致了解架构和数据流就行,以点及面。当然最好的情况是,处理线上问题,通过解决问题来加深自己的理解。说白了就是得耐住性子,多看文档,配合文档Debug,当然这一切的前提是有足够的时间。

那么怎么速成呢?现在的领导总是全都要,不给时间又要慢工出细活,我说给我一天48小时,他说我全都要,突出一个你说你的我说我的。当然,这活还得做,有个人安慰我的时候说,钱难挣x难吃,emmm,换人我就开怼了,但对他不行,哈哈。作为菜恐龙必然也是有速成的法子,最极速的方法就是做无情IO机器,我不需要关心从头到尾的逻辑,我只需要了解最后的结果是什么样。然后顺着核心字段往上溯源,了解每个字段的来源,做好这个准备之后,看需求是怎么样的,如果是新的需求,那么就完全隔离,数据库表和代码独立出来,不要与之前的代码耦合。如果是在以前的基础上修改,那就叠叠乐,只增不减,不要乱动,就像代理模式一样,尽量不要动之前的。

说的比较抽象嗷,接下来还是实践出真知吧,注意了,朋友们,看我操作!

勇者前进中

我在重构时遇到最大的问题就是业务和数据都不熟悉,属于是空降菜恐龙。和我一起重构计算服务的小伙伴是项目的接盘开发和运维,所以他会相对熟悉,我想也是,不然两人都不懂,这不废了,还重构啥,我选叠叠乐!限于一个月的开发时间,我要在极短的时间内从头理解,还要深刻理解一个已经持续开发运维超过一年半的核心系统,我选择裂开。

......莫西莫西,大家好,这里是菜鲤鱼,菜恐龙去休息弥补裂开的心了,我将代替他来讲讲重构中的大事件,敲黑板了嗷,大家注意听,listen to me please!

设计模式

设计模式大家肯定是常用啊,即使你没有刻意使用,也会无意间用到一些,比如建造者模式、装饰器模式、代理模式、外观模式。我自己对于设计模式的看法是,如果没有清晰的思路就别用,因为大部分需要设计的代码绝对会增加代码的复杂度,相比带来的扩展性之类的好处,理解门槛的提升反倒是会先给人一榔头。当然你要是做架构的话,肯定还是玩一下设计模式比较好,做业务系统的话,怎么舒服怎么来吧,不要刻意。

我在项目中重点使用了模板模式和单例模式两种设计模式,模板模式用于梳理代码逻辑同时为后续的优化提供了代码基础,单例模式则是基于现状的一个性能优化。

模板模式

前面也提到了,我重构的是计算服务,不过这个计算服务也算是比较单纯,主流程是为订单行组装数据,具体可以拆分为十个子计算。每个子计算也能大致拆分为三种代码,数据准备、数据计算、数据处理及回收。数据准备是从数据库或者接口之类的数据源获取计算需要的数据。数据计算则是拿着准备好的数据,按照业务逻辑进行计算。数据处理及回收,数据处理无非就是入库或者传递到下一个子计算做准备,回收是将无用大对象主动置为null,因为计算流程总耗时会有50秒左右,所以要通过null优先GC(不一定有用,后续出对比文章)。基于这样的代码结构,得到了一个适用于大部分计算模块代码的模板抽象类

import lombok.extern.slf4j.Slf4j;
@Slf4j
public abstract class AbstractCalculation<T> {


    /**
     * 任务数据准备
     */
    public abstract <T> T prepare(CommonDTO commonDTO);

    /**
     * 任务数据计算
     */
    public abstract boolean calculation(T t, CommonDTO commonDTO);

    /**
     * 任务结果处理
     */
    public abstract boolean handle(T t, CommonDTO commonDTO);
    /**
     * 任务执行方法,用于调用上面三个抽象方法
     */
    public Boolean process(CommonDTO commonDTO) {
        String taskName = this.getClass().getSimpleName();
        long startTime = System.currentTimeMillis();
        T prepare = prepare(commonDTO);
        long endTime = System.currentTimeMillis();
        log.info("任务数据准备--消耗时间:{}秒,任务名称:{}", (endTime - startTime) / 1000, taskName);
        startTime = System.currentTimeMillis();
        calculation(prepare, commonDTO);
        endTime = System.currentTimeMillis();
        log.info("任务计算--消耗时间:{}秒,任务名称:{}", (endTime - startTime) / 1000, taskName);
        startTime = System.currentTimeMillis();
        handle(prepare, commonDTO);
        endTime = System.currentTimeMillis();
        log.info("任务结果处理--消耗时间:{}秒,任务名称:{}", (endTime - startTime) / 1000, taskName);
        return Boolean.TRUE;
    }
}

一期的计算服务说实话,上万行而且有两三轮开发的叠叠乐,已经成了依托答辩,代码注释呢,有但不完全有,这就不细说了,相信大家也有类似的遭遇。但恰好是因为叠叠乐,所以主计算能根据类拆分组合后能得到具体的子计算代码,因此我和另一个小伙伴首先确认做的事就是将一期的代码梳理分类,搬过来实现模板抽象类,大致如下。

@Service
@Slf4j
public class MaretNameMatchTemplate extends AbstractCalculation<MarketCodeNameMatchParam> {
    private final XXX xxx;
    public MaretNameMatchTemplate(XXX xxx) {
        this.xxx = xxx;

    }
    @Override
    public MarketCodeNameMatchParam prepare(CommonDTO commonDTO) {
        //TODO 调用各类接口或方法准备数据a和b
        return new MarketCodeNameMatchParam(a, b);
    }
    @Override
    public boolean calculation(MarketCodeNameMatchParam param, CommonDTO commonDTO) {
        //TODO 数据计算
         return true;
    }    
    @Override
    public boolean handle(MarketCodeNameMatchParam param, CommonDTO commonDTO) {
        param.setA(null);
        param.setB(null);

        return true;
    }
}

说是搬也不是完全搬,除了最基本的分类代码,还要同时做优化,将循环里能拆出来的IO操作,提出来放到数据准备方法中做批量或者全量MAP。注意,重点来了,既然子计算能这样拆分,主计算流程自然也能这样拆分。

@Service
@Slf4j
public class ConcreteCalculation extends AbstractCalculation<DelculationData> {
    @Autowired
    private PlatformTransactionManager transactionManager;
    @Autowired
    @Qualifier("ioDenseExecutor")
    private ThreadPoolTaskExecutor ioDense;
    private final ManualFilingOtdTimeTemplate manualFilingOtdTimeCulation;
    private final ErpOrderUpInsertTemplate erpOrderUpInsert;
    private final DelCulationOtdTimeTemplate otdTimeCulation;
    private final MaretNameMatchTemplate maretNameMatchTemplate;
    private final ResourceAllocationTemplate resourceAllocationTemplate;
    private final RemoveDataTemplate removeDataTemplate;
    private final ReleaseOccTemplate releaseOccTemplate;
    private final SuggestDeliveryCalculationTemplate suggestDeliveryCalculationTemplate;
    private final CalculationAppointDeliveryTemplate calculationAppointDeliveryTemplate;
    private final CommittedDeliveryService committedDeliveryService;


    public ConcreteCalculation(ManualFilingOtdTimeTemplate manualFilingOtdTimeCulation,
                               ErpOrderUpInsertTemplate erpOrderUpInsert, DelCulationOtdTimeTemplate otdTimeCulation,
                               MaretNameMatchTemplate maretNameMatchTemplate,
                               ResourceAllocationTemplate resourceAllocationTemplate,
                               RemoveDataTemplate removeDataTemplate, ReleaseOccTemplate releaseOccTemplate,
                               SuggestDeliveryCalculationTemplate suggestDeliveryCalculationTemplate,
                               CalculationAppointDeliveryTemplate calculationAppointDeliveryTemplate,
                               CommittedDeliveryService committedDeliveryService) {
        this.manualFilingOtdTimeCulation = manualFilingOtdTimeCulation;
        this.erpOrderUpInsert = erpOrderUpInsert;
        this.otdTimeCulation = otdTimeCulation;
        this.maretNameMatchTemplate = maretNameMatchTemplate;
        this.resourceAllocationTemplate = resourceAllocationTemplate;
        this.removeDataTemplate = removeDataTemplate;
        this.releaseOccTemplate = releaseOccTemplate;
        this.suggestDeliveryCalculationTemplate = suggestDeliveryCalculationTemplate;
        this.calculationAppointDeliveryTemplate = calculationAppointDeliveryTemplate;
        this.committedDeliveryService = committedDeliveryService;
    }


    @Override
    public DelculationData prepare(CommonDTO commonDTO) {
        DelculationDataBuilder builder = new DelculationDataBuilder();
        DelculationDataDirector director = new DelculationDataDirector(builder);
        DelculationData delculationData = director.create();

        log.info("**人工报备计算开始**");
        manualFilingOtdTimeCulation.process(commonDTO);
        log.info("**人工报备计算结束**");

        log.info("**erp订单新增或修改开始**");
        erpOrderUpInsert.process(commonDTO);
        log.info("**erp订单新增或修改结束**");

        log.info("**出货数据释放库存开始**");
        releaseOccTemplate.process(commonDTO);
        log.info("**出货数据释放库存结束**");
        return delculationData;
    }

    @Override
    public boolean calculation(DelculationData concreteDelculation, CommonDTO commonDTO) {
        //OTD时效计算,计算后的数据进交期计算表
        log.info("**交期数据otd计算开始**");
        otdTimeCulation.process(commonDTO);
        log.info("**交期数据otd计算结束**");
        log.info("**市场代码名称匹配开始**");
        maretNameMatchTemplate.process(commonDTO);
        log.info("**市场代码名称匹配结束**");
        log.info("**约定交期计算开始**");
        calculationAppointDeliveryTemplate.process(commonDTO);
        log.info("**约定交期计算结束**");
        log.info("**建议交期计算开始**");
        suggestDeliveryCalculationTemplate.process(commonDTO);
        log.info("**建议交期计算结束**");
        log.info("**资源分配计算开始**");
        resourceAllocationTemplate.process(commonDTO);
        log.info("**资源分配计算结束**");
        return true;
    }

    @Override
    public boolean handle(DelculationData concreteDelculation, CommonDTO commonDTO) {
        List<OdDeliveryCalculation> updateList = commonDTO.getUpdateList();
        if (!updateList.isEmpty()) {
            Instant end1 = Instant.now();
            batchSchedule(updateList);
            log.info("============更新耗时:{}s===================", ChronoUnit.SECONDS.between(end1, Instant.now()));
        }
        //删除过期数据
        log.info("**删除过期数据开始**");
        removeDataTemplate.process(commonDTO);
        log.info("**删除过期数据结束**");
        commonDTO.setDeliveryList(Collections.emptyList());
        commonDTO.setUpdateList(Collections.emptyList());
        commonDTO.setMarketStockNum(Collections.emptyMap());
        commonDTO.setMarketOccupyNum(Collections.emptyMap());
        return true;
    }

    private void batchSchedule(List<OdDeliveryCalculation> addList) {
        if (!CollectionUtils.isEmpty(addList)) {
            AtomicBoolean isSuccess = new AtomicBoolean(true);
            AtomicInteger cur = new AtomicInteger(1);
            List<Thread> unfinishedList = new ArrayList<>();
            //切分新增集合
            List<List<OdDeliveryCalculation>> partition = Lists.partition(addList, 100);
            int totalSize = partition.size();
            //多线程处理开始
            CompletableFuture<Void> future =
                    CompletableFuture.allOf(partition.stream().map(addPartitionList -> CompletableFuture.runAsync(() -> {
                        DefaultTransactionDefinition defGo = new DefaultTransactionDefinition();
                        defGo.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
                        TransactionStatus statusGo = transactionManager.getTransaction(defGo);
                        int curInt = cur.getAndIncrement();
                        try {
                            committedDeliveryService.updateBatchById(addPartitionList);
                            synchronized (unfinishedList) {
                                unfinishedList.add(Thread.currentThread());
                            }
                            notifyAllThread(unfinishedList, totalSize, false);
                            LockSupport.park();
                            if (isSuccess.get()) {
                                transactionManager.commit(statusGo);
                            } else {
                                transactionManager.rollback(statusGo);
                            }
                        } catch (Exception e) {
                            transactionManager.rollback(statusGo);
                            isSuccess.set(false);
                            notifyAllThread(unfinishedList, totalSize, true);
                        }
                    }, ioDense)).toArray(CompletableFuture[]::new));
            future.join();
        }
    }

    private void notifyAllThread(List<Thread> unfinishedList, int totalSize, boolean isForce) {
        if (isForce || unfinishedList.size() >= totalSize) {
            for (Thread thread : unfinishedList) {
                log.info("当前线程={}被唤醒", thread.getName());
                LockSupport.unpark(thread);
            }
        }
    }
}

CommonDTO是后加的,原因是当时在填充模板的时候,我想到了一个问题,为什么不进一步合并数据准备模块呢。虽说各个子计算相对独立,但总是会查一些差不多的数据,那么要主动建立一个连接,减少重复数据的查询,因此加入了CommonDTO这个数据传递参数。下面数据处理也是一点小技巧,因为部分数据最终会放到同一张表,所以前面的子计算就不要去更新数据库,通过CommonDTO传递,直到最后统一入库,减少数据库操作。这里用到了多线程事务,一开始是没打算用的,结果实测时发现更新一千条数据需要40秒,可能是因为单表九十多个字段太多了吧,没办法就换了。多线程事务开启后提升到了10秒内,相关细节见性能优化-如何爽玩多线程来开发 - 114收藏

最后总结一下,引入模板设计模式带来的最明显的好处就是代码结构清晰,每个模板实现类是什么功能一目了然。除此之外数据准备、数据计算、数据处理代码块分类清晰,便于后续的优化和迭代运维。

单例模式

OGG采集的是Oracle订单表的变化,为了保证顺序性,推送Kafka的Topic分区设置为1。因为分区设置为1,所以同一个消费者组里只能有一个消费者进行消费,计算的并行度为1。为了保证消费速度足够快,在提高计算速度的同时将整个计算流程改造为了支持批量,因此设置Kafka消费单次抓取1000条。为了保证消息在计算报错的时候不丢失,选择手动提交消费偏移量,同时根据计算时间拉长了Kafka消费间隔时间,参数配置可以看消息积压问题难?思路代码优化细节全公开

@Bean
public ConcurrentKafkaListenerContainerFactory<String, String> onceFactory() {
    ConcurrentKafkaListenerContainerFactory<String, String> container = new ConcurrentKafkaListenerContainerFactory();
    Map<String, Object> props = new HashMap(16);
    props.put("bootstrap.servers", this.prop.getKafkaServers());
    props.put("key.deserializer", StringDeserializer.class);
    props.put("value.deserializer", StringDeserializer.class);
    props.put("group.id", StringUtils.isEmpty(this.prop.getConsumerGroupId()) ? "onceConsumerGroup" : this.prop.getConsumerGroupId());
    props.put("fetch.min.bytes", 1048576);
    props.put("max.poll.records", 1000);
    props.put("max.poll.interval.ms", 300000);
    props.put("enable.auto.commit", "false");
    container.setConsumerFactory(new DefaultKafkaConsumerFactory(props));
    container.setConcurrency(1);
    container.setBatchListener(true);
    container.getContainerProperties().setAckMode(AckMode.MANUAL);
    return container;
}
@KafkaListener(topics = {"ERP_YC_CUX"}, containerFactory = "onceFactory")
public void oggListener(List<ConsumerRecord<String, String>> records, Acknowledgment ack) {
    //TODO 消费消息并计算    
    ack.acknowledge();
}

这里单例模式的应用其实就是一个本地缓存,因为单线程的限制,所以没有必要用分布式缓存Redis,用了反而增加IO开销。使用方法很简单,定义一个单例,内部属性就是我们需要的部分数据,采取旁路缓存模式。

/**
 * @author WangZY
 * @classname CommonData
 * @date 2023/3/15 17:45
 * @description 共享变量
 */
@Data
public class CommonData {
    private volatile static CommonData instance;

    private CommonData() {
    }

    public static CommonData getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new CommonData();
                }
            }
        }
        return instance;
    }

    /**
     * 物料ID--实体
     */
    private Map<Long, MtlSystemItemsB> itemIdMap;
    /**
     * 物料编码--实体
     */
    private Map<String, Long> itemCodeMap;

}
/**
 * @author WangZY
 * @classname InitDataTask
 * @date 2023/3/15 18:15
 * @description 初始化数据
 */
@Component
public class InitDataTask {
    private final IMtlSystemItemsBService itemService;
    private final IOdDeliveryCenterService centerService;
    private final IOdsErpStockAgeDService stockAgeService;

    public InitDataTask(IMtlSystemItemsBService itemService, IOdDeliveryCenterService centerService,
                        IOdsErpStockAgeDService stockAgeService) {
        this.itemService = itemService;
        this.centerService = centerService;
        this.stockAgeService = stockAgeService;
    }

    @PostConstruct
    public void initData() {
        CommonData instance = CommonData.getInstance();
        List<MtlSystemItemsB> list = itemService.lambdaQuery().select(MtlSystemItemsB::getInventoryItemId,
                MtlSystemItemsB::getSegment1).list();
        Map<Long, MtlSystemItemsB> itemIdMap = new HashMap<>();
        Map<String, Long> itemCodeMap = new HashMap<>();
        for (MtlSystemItemsB item : list) {
            Long itemId = item.getInventoryItemId();
            String itemCode = item.getSegment1();
            itemCodeMap.put(itemCode, itemId);
            itemIdMap.put(itemId, item);
        }
        instance.setItemCodeMap(itemCodeMap);
        instance.setItemIdMap(itemIdMap);
   }
}

CommonData是一个经典的双重检查锁单例写法,在InitDataTask方法使用@PostConstruct在spring boot启动时执行一次,做了一下缓存预热。

image.png 这里也是有个小细节,CommonData内部有Map itemIdMap和Map itemCodeMap两个属性,这里都是从物料表获取数据。如果直入直出的写,会像上图一样按照物料ID和物料编码为key,实体类为value各做一个map。但是该表的物料ID和物料编码其实是一对一的对应关系,因此通过物料编码-->物料ID-->实体类的桥接,也能达到同样的效果,但是用桥接的物料ID做value比实体类更省内存。

代码优化

优化IDE提示问题的代码

image.png

随便拉个同事的代码上来鞭打,当时我说我改完了之后让你看看我怎么改的,但我也不知道截至发文这天你有没有看,反正是感谢(咬牙切齿)友情提供的素材。此类问题很多,因为我用的IDEA,会有高亮提示,所以一般看到了就会清掉,大家也要养成良好的代码习惯,点一点就完事了。

老生常谈的合并查询

详见查询接口性能优化实录,讲点新手也能用的的优化手段一栏,里面有提到List转Map和批量查询两种手段。合并查询的目的就是一次性查出所有需要的数据,通过代码建立联系,而不是循环一个个查询。打个比方,我现在是一千条批量消费,需要循环一千次填充订单的某些字段,这时候就不可能在循环里进行数据库查询,因为哪怕是几百毫秒乘以一千也是非常恐怖的时间消耗。

减少重复查询

这是很多老项目的通病了,几轮开发下来,可能不自觉间就积累了大量重复查询,所以做代码梳理时就尤其要注意此类问题,及时解决。减少重复查询不仅仅要删,还要改,加缓存往往是好的手段。

image.png

减少数据量

因为订单交付项目的数据源繁杂多样,我们不能控制他们怎么传过来,但是我们能做的是在接收端尽量精简字段,从而减少数据量。比如订单交付经常会从ERP中通过多数据源框架直接读取数据,我没见过别的ERP系统,反正我们这个是比较离谱,像是这种几百个字段千百万甚至过亿数据量的表比比皆是。

image.png

这种情况下肯定就不能几百个字段一块拿了,很容易触发OOM问题,几个G肯定不够你使的,抽取关键字段就尤为重要。

image.png 不要偷懒,mybatis-plus之类的框架固然好用,但是该优化的时候,就老老实实写Sql生成转换类。

数据库表设计优化

口语化讲解数据库优化 - 掘金 (juejin.cn)之前在这篇优化的文章中提到过三大数据库优化方向,减少数据量、空间换时间、更换架构。这里减少数据量,我还用到了增加冗余字段以及生成结果表两种优化手段。

冗余字段,因为主表的字段接近100个字段,所以不能随便加字段,避免单表性能下降。因此字段加不加,我的判断依据有两个,是否计算过程中能用到和是否能验证计算结果。计算中能用到但是不会最后展示的字段,比如物料ID,订单ID这种用于关联其他表的字段,我认为是有必要存一份,不是所有人都有查日志和记日志的好习惯,适当地给同事一点暖心的记录是一件好事。再说一般ID都是数字类型,说实话不占太多空间,何必在意那一点损失呢。还有一些字段能验证计算结果,比如计算的中间值,有时候与其费劲巴拉地刨日志,不如直接就在表里记录上就好了,简单明了。

结果表大多是我们通过Flink处理后的一些中间表数据,或者数仓那里直接取过来的部分数据,算是省了大量的Sql联查。我做这个项目一开始真的是两眼一黑,ERP组的同事老是动不动甩过来一些几百行带函数和视图的大Sql,说实话,我感觉看到这Sql,一点想法都没有了,只想摆烂。一张表最少几十个字段,就一小半有注释,有的字段就是那什么好听点叫扩展性字段,比如segement1、segement2.....14,我也不知道为啥这些123456的为啥就扩展了。我司买的用友的ERP,话说有没有用友的老哥来讲讲你们表真的这么设计吗,太抽象了吧!

image.png 这是计算过程中用到的一个ERP提供的订单基础信息的Oracel Sql,红框圈起来的是程序包,里面一般是几十行的一个子查询。Sql大概都这个样子,我也不清楚为啥怎么ERP那边怎么搞着搞着就成这个样子了,仿佛不叠大Sql就没法开发了一样。(⊙﹏⊙),看到这个的ERP同事,下回还求你们帮我改Sql,我是真不会啊,这Sql我看了一眼黑。

多线程多线程

作为我最熟练也是最喜欢的优化手段,我自己写了一些代码片拿来即用,非常方便也是非常有效的操作,专治大循环以及大批量数据库操作。性能优化-如何爽玩多线程来开发 - 114收藏关于多线程的宝藏我都放在这篇文章里了,大航海时代,开始了(笑)。

计算可靠性

计算服务、计算服务,那自然不能只说性能,可靠性也是必须保证的重要一环。有测试过全量计算一次是在5分钟左右,因此计算服务采用实时增量加定时全量两套方案并行。实时自然是保证时效性,每半小时会有一次计算结果的最后更新时间查询,如果这半小时没有更新数据,那么启动一次全量计算并通知开发人员进行人工处理。定时作为兜底,保证即使定时出现问题,也能保证半小时的时效,不至于完全不能用。

实时部分为了收集异常数据,增加了异常信息表,任务重试3次仍然失败,则发送消息通知人工处理。因为要保证消息顺序性,所以要手动提交消费偏移量,即使消息积压也要拒绝顺序导致的数据处理异常。前面提到的全量是从ERP的原计算结果表中取数据,因此不存在顺序问题,这个在考虑之中不需要担心。嘻嘻,不好意思,再贴一篇我写的处理消息积压的文章,消息积压问题难?思路代码优化细节全公开 - 掘金 (juejin.cn)。哎呀,怎么什么倒霉问题都让我遇上了,方便我王婆卖瓜来着。突然有个想法,如果我去带货会不会是一把好手,真是一把子无语住了,家人们,谁懂啊!(你干嘛,哎呦)

最后整了几个接口作为计算入口,方便人工介入处理,作为开发留一个小小的后门不过分吧。

少男祈祷中(复盘)

这会儿写文的时候,听到这首歌,跟着摇起来了,很有感觉くびったけ-yama,突然感觉轻快了起来。

从年前两周被通知要重构到年后突然成为这个被重构核心项目的负责人再到今天,发生了很多很多事,但是一看时间,也就是不到两个月的时间。原计划是一个月弄完,但是中间夹杂着其他项目的活,真正开始做重构到现在也就是一个月的开发时间吧。现在首先是说明一下我这个负责人的情况,在努力接受项目的业务逻辑中,目前是能对整个项目有个基础的了解,计算服务相对熟悉些,但是说白了还是需要别人的帮助才能找问题。可能有各种各样的借口,比如我同时开发着两三个项目,有着各种各样的运维和问题处理,生活中也有些不如意的地方,导致我最近情绪不佳,攻击性很强。对被攻击过的同事说一声抱歉,我的问题,事后我也会主动下个话,毕竟大家都是来上班的,不是来受气的。

计算服务重构从一开始就是我和另一个小伙伴的二重奏,说真的,做的过程中太难了,没人能帮忙。也没有人手抽过来帮助我俩,无他,项目业务复杂度上来了,短时间上不了手,包括我也是,即使到现在我也没法说一句我懂这个项目,查问题依旧需要别人的帮助。因为计算这个和页面逻辑不一样,他没有一个明确的入口,有时候就是无从查起,想使劲都没招,特别是小伙伴马上跳槽跑路了,剩我一个独苗。我踏马真的是,心累了,上头了。

说回复盘,我的感觉就是混乱,从上到下没有一个完整的项目管理流程。一切的起源来自于当时被通知我要参与主导重构,然后...emmm,就没有然后了。让另一个小伙伴给我讲了下大致的计算流程就完事了,也没说怎么做,就是主打一个自由发挥,我只能说感谢领导信任。不过还是有一些准备的,比如梳理了下计算需要的字段来源,对部分Sql做了解释,问题是这远远不够啊。实时的依靠是什么,就只有一个Flink吗?从我的角度来看,为什么要用Flink,是需要理由的,不是说什么东西往Flink上一套就成了实时XX,这玩意是工具不是互联网+创业大赛,哪有那么好用?

最后通过Kafka来做实时是结合前期数据准备和需求说明耗时三天敲定的方案,说实话,这就离谱。我们重构是在项目开始半个多月时才敲定的最终完整方案,要知道给的开发时间一共也就一个月时间,因此在我看来,时间的紧迫、极其匮乏的准备还有开发同时背着多个项目并行,导致了重构开局的极限地狱。如果我要做一个项目的重构,首先确保核心开发人员必须专注于这个项目,不会三心二意导致思路中断,同时该给的数据准备和业务逻辑一定要清晰,数据准备就不说了,至少保证每个给出来的Sql取值和逻辑正确吧,别老是开始用了才知道哪哪都不行,孩子没了才知道奶了,这能行嘛。还有业务逻辑也是,换了新的PM接手,后端开发一共三人就一个人懂业务,差点凑两队卧龙凤雏了。不过也得亏PM漏需求了,不然后面这个多出来的一个月开发时间还不知道咋争取到。

计算服务确定好方案后,还是度过了比较顺畅的开发周期,就是搬老代码同时做优化。优化比较费工夫,因为老代码部分已经优化过了,要想深度优化的话,必然是进行相当程度的代码改动,水磨工夫不可少。我和小伙伴是采取和平分工,整个消息消费和计算主线由我来构建,再各自负责一半计算模块,由相对熟悉的小伙伴负责跨计算模块的数据抽取优化,我来补充整体的可靠性开发。

计算主流程的重构虽说磕磕绊绊,但也算是告一段落了,从我个人的角度来看,我是不满意的,因为还有大把性能上的问题没有解决。内存溢出过、CPU百分百过、GC过于频繁以及连物理机的网络带宽都爆了,真的是问题多多,这些问题,后续会出文章单独讲解。站在公司的角度,我认为同样也是难以满意的,虽说从结果上看仅靠两人在一个多月的时间就完成了一个核心项目的重构,并且由普通的定时更新服务升级到了实时计算,这是一个相当性价比的结果。但是这就导致一个问题,我俩是完全没有任何文档留痕的,包括整个方案的确定都是口头定下来的,除了我俩再来个人他都不知道写了啥。现在小伙伴跳槽(恭喜恭喜),也给我留下了巨大的难题,我该如何去接手他负责的模块,怎么才能快速响应用户的问题并解决,单单靠一份简单的交接文档,我觉得是很难做到的。哎,我现在也没啥好的方案,如果读者有遇到类似的问题,可以私信或者回复我,我最近真的很头大,不知道该怎么解决这些难题。

话说回来,这算不算又是一个新的文章素材呢。我上周还和一个同事打趣说,别老是给我制造性能缺口了,多亏了大家,我的素材源源不断。本次重构的过程中也解决了大量的问题,从低到高各种错误毛病都有,可惜我忘了记一下了,下次一定好好截图,记录下大家的高光时刻。不过我自己也是有很多犯错的时候,详见鲨逼操作记录有时候需要打开思路-开膛手参上(含错误思路详细记录) - 掘金 (juejin.cn),错误是很正常的,没人不犯错,及时改正就OK。

写在最后

首先跟大家说一声抱歉,上周五写的时候,感觉压力太大了,突然写不下去了,思路就断了。今天2023.03.26下午想要快速进入状态,但是一直卡着没进去,代码优化这块算是拉了,用了一些之前的文章做内容填充,导致整篇文章有点虎头蛇尾了,没有达到我自己预期的效果。最近一两月吧,事情非常多,一下子全压到我头上,有点蚌埠住了,我知道这也是个借口吧,自己骗自己短暂休息下。我也不求大家同情,过一阵子自己就好了,年轻人哪有那么矫情,这年头大家都很难,唯一抱歉的是影响了写作状态,没有把这次难得的经历百分百呈现出来,有点对不起大家,也不太能对得起自己的付出,哎。

文章会有一些割裂感,因为我是这一周多断断续续下班后写的,思路不连贯,有时候我也没法很好地衔接起来。状态有点不好,但是也不能一直停滞不前,所以本文还是尽量以我当前最好的状态展示出来了,希望能对大家有一点小小的帮助。其实在掘金写文章对我来说算是一种解压的重要手段,不知道大家能不能看到我文章的收藏数,我今天看了看,757点赞719收藏。收藏数这么高,我是很高兴的,证明我写的文章确实是很好的帮助了,而且我有两篇我觉得写的非常好的文章都有超过一百的收藏数,对我来说有着极强的正反馈。真的谢谢大家的捧场,我也一定会为大家持续产出高质量有深度的文章。

最后的最后,再次强调一次我的人生信条,做一个快乐的人,在这痛苦压抑的世界绽放幸福快乐之花!向美好的世界献上祝福,诸君共勉,下一篇文章不见不散!!!