我是怎么样优化API的?

cc
cc
2023-03-04 / 0 评论 / 99 阅读 / 正在检测是否收录...

背景

在系统开发过程中,以迭代功能为主,在使用过程中遇到性能瓶颈再进行针对性优化。本次遇到的问题是在导入3000+FAQ时导入时间大概500S左右,用户端提示导入失败,结果数据在导入中。

文章出现的代码经过处理,不包含任何具体隐私信息,仅还原场景

概念:

  • FAQ:一般指常见问题解答。常见问题解答(frequently-asked questions,简称FAQ)即:一个问题对应一个答案。他的结构大概是一组相关的问题和一个答案。比如“你是谁?”这个问题,他会有不同的问法:“你叫什么?你叫啥?”,然后对应一个答案:"我是xxx"
  • 意图/Intent:即FAQ中的“你是谁”

目标

1、在3000条数据时,导入在时间优到5秒内(而用户能够忍受的最长等待时间在6~8秒之间);
2、优化查询,返回时间在1S内;

步骤

1、找出瓶颈点;
2、确定优化措施;
3、实施;

这三个步骤看似简单,其实需要在开发过程中逐步积累出自己的套路及经验。

FAQ批量导入

这里从系统csv文件中读取出意图和答案,再将数据保存到数据库,然后做一些检查,把错误的信息打上标签。

  • 解析数据
  • 保存
  • 校验、更新

找出瓶颈点

首先,我们发现API慢时,我们要知道哪些步骤慢。 这里我们用到google的Stopwatch进行“打桩”,给个操作计时。

    public static void main(String[] args) {
        // 创建后可多次输出耗时
        Stopwatch stopwatch = Stopwatch.createStarted();
        ThreadUtil.sleep(1000);
        System.out.println("消耗时间:" + stopwatch.elapsed(TimeUnit.MILLISECONDS));
        ThreadUtil.sleep(2000);
        System.out.println("消耗时间:" + stopwatch.elapsed(TimeUnit.MILLISECONDS));
    }

消耗时间:1001
消耗时间:3009

回到我们的导入FAQ代码

   public void save(String faqId, MultipartFile file) throws Exception {
        Stopwatch stopwatch = Stopwatch.createStarted();
        // 1、从faq文件解析出数据
        List<List<String>> rows = _readRows(file);
        LOG.info("[import faq - finish check used:]", stopwatch.elapsed(TimeUnit.MILLISECONDS));

        // 获取一个链接
         Connection conn = getConn();

        // 一个FAQ由一个User、一个Bot组成
        // 转换成faq pair 对
        List<Pair<User, Bot>> details = _convertRows(rows, faqId);
        LOG.info("[import faq - convert: ]", stopwatch.elapsed(TimeUnit.MILLISECONDS));

        // 保存到数据库
        for (Pair<User, Bot> p : details) {
            ComponentDao.add(conn, p.getKey().toComponent());
            ComponentDao.add(conn, p.getRight().toComponent());
        }

        LOG.info("[import faq - save 2 db: ]", stopwatch.elapsed(TimeUnit.MILLISECONDS));
        conn.commit();

        LOG.info("[import faq - finish commit 2: ]", stopwatch.elapsed(TimeUnit.MILLISECONDS));
        _validate(faqId, conn);
        conn.commit();
    }

    private void _validate(String faqId, Connection conn, String dbName) throws Exception {
        Stopwatch stopwatch = Stopwatch.createStarted();
        // 1、创建一个上下文,携带一些信息
        ComponentValidatorContext context = _buildContext(componentId, conn);
        LOG.debug("[validate faq - build context:{}]", stopwatch.elapsed(TimeUnit.MILLISECONDS));

        // 2、 query 当前节点所在的validator,将user、bot转换成校验器
        Pair<ComponentValidator /*Faq Validator*/, List<ComponentValidator>/*children validators*/> validatorPair = _validators(context);
        LOG.debug("[validate faq - build validate pair:{}]", stopwatch.elapsed(TimeUnit.MILLISECONDS));

        // 3、遍历节点,更新错误信息
        LOG.debug("[validate faq - start validate with length:{}, spend:{}]", validators.size(), stopwatch.elapsed(TimeUnit.MILLISECONDS));
        int writeDbCount = 0;
        boolean hasError = false;
        for (ComponentValidator validator : validators) {
            StatusCodes statusCodes = validator.validate(context);
            if (statusCodes != null) {
                hasError = true;
            }

            // 节点错误
            boolean updated = updateComponentError(validator);
            if (updated) {
                ComponentDao.update(conn, validator.getComponent());
                writeDbCount++;
            }
        }

        LOG.debug("[validate faq - finish validate with write 2 db count:{}, spend:{}]", writeDbCount, stopwatch.elapsed(TimeUnit.MILLISECONDS));

        // 3、更新FAQ上的标记
        _updateError(faqId, hasError);
        LOG.debug("[validate used in millis:{}]", stopwatch.elapsed(TimeUnit.MILLISECONDS));
    }

运行结果

[import faq - finish check used:147]
[import faq - convert:241]
[import faq - finish save 2 db:50870]
[import faq - finish commit:50878]
[start validate]
[validate faq - build context:33486]
[validate faq - build validate pair:35203]
[validate faq - start validate with length:6795, spend:35203]
[validate faq - finish validate with write 2 db count:6795, spend:361809]
[validate used in millis:361818]

到此,我们可以从打桩信息中得出瓶颈:

  • 保存到数据库,花费50s+
  • 构建上下文:33s+
  • 检验所有节点:326s+

确定优化措施

1、保存到数据库,花费50s+

代码:

        // 保存到数据库
        for (Pair<User, Bot> p : details) {
            ComponentDao.add(conn, p.getKey().toComponent());
            ComponentDao.add(conn, p.getRight().toComponent());
        }

这里有3397个FAQ,6794个User、Bot节点;那么就执行6794次save操作。

方案:结合以往经验,这里我们做批量保存。

  • 减少db写入次数, 批量写入;
  • mysql批量写入优化;

1、改为批量写入 : 写入db次数变成了 14次

private void _saveBach(List<Pair<RestProjectComponentUser, RestProjectComponentBot>> data,
                           Connection conn, String dbName)throws SQLException {
        String sql = "INSERT INTO tableName (id, xx, xx) VALUES (?, ?, ?) ";

        PreparedStatement ps = conn.prepareStatement(sql);

        for (int i = 0; i < data.size(); i++) {
            Pair<User, Bot> item = data.get(i);
            Component user = item.getKey().toComponent();
            Component bot = item.getValue().toComponent();

            _add2Bach(ps, user);
            _add2Bach(ps, bot);

            // 每500次提交一下
            if (i % 500 == 0) {
                ps.executeBatch();
                conn.commit();
                ps.clearBatch();
            }
        }

        ps.executeBatch();
        conn.commit();
        ps.clearBatch();
    }

    private void _add2Bach(PreparedStatement ps, Component component) throws SQLException{
        ps.setString(1, component.getId());
        ps.setString(2, component.getXX());
        ps.setString(3, component.getXx());
        ps.addBatch();
    }

2、mysql批量写入优化
修改链接信息,重写批量写入

jdbc:mysql://127.0.0.1:3305?rewriteBatchedStatements=true

验证

## 时间从50S+到1s+
[import faq - finish save 2 db:1586]

总结: 这里原来每次保存都要写入数据库,大的数据量下这效率必然是低下的。经过批量保存和重写批量insert的sql,这减少了写入db次数,提高了sql批量写入效率

# 之前:6794次insert
insert into xx (xx,xx) values (xx,xx)

# 之后:14次批量insert 
INSERT INTO xx (xx,xx) values ('xxx','xxx'),('xxx','xxx')...

2、构建上下文:33s+

针对构建上下文,我们在再次打桩,查找瓶颈点

[build - context start build validate context]
[build - convert2Rest - start]
[build - convert2Rest - find root & query all projects spend:652]
[build - convert2Rest - load children:10113]
[build - convert2Rest - covert children:22067]
[build - build - context convert2Rest pair spend:22072]
[build - build - context spend:32382]

这里大概的逻辑:

  • 查询顶级节点、查询当前项目下所有的节点信息
  • 还原关联关系
  • 从db对象转换成Rest对象
  • 筛选一些节点

优化的点

  • 多次查询当前项目的所有节点-这数据量大,多次重复操作

    • 这在只查询一次,其他方法用到所有节点时通过参数传入
  • 还原关联关系这用了递归,实现简单

    • 数据量大时效率不如 while /for 循环 -- 改为while + for
  • 从db对象转换成Rest对象-在循环中查询整个项目数据

    • 减少重复查询db
    • 循环中使用stream,筛选/转换少量 - 这种场景下效率不如for
  • 筛选一些节点

    • 减少db查询
    • 递归转while /for

验证

[build - context start build validate context]
[build - convert2Rest - start]
[build - convert2Rest - find root & query all projects spend:3]
[build - convert2Rest - load children:15]
[build - convert2Rest - covert children:124]
[build - context convert2Rest pair spend:128]
[build - context spend:214]

总结:

  • 减少db查询
  • 合理使用stream
  • 递归转while / for实现

这优化还是很明显,从32S+到0.2S。

3、检验所有节点:326s+

这是最耗时的地方,先分析下代码:

  • for循环所有节点,执行校验逻辑 - 验证意图(Intent,用户节点的输入需要唯一)不能重复
  • 如果有错误,将错误信息更新并同步数据库
finish validate with write 2 db count:6795, spend:361809

这提示总共写了6795次db,这显然不合理,因为在测试的时候是全新的数据,不会重复。

        int writeDbCount = 0;
        boolean hasError = false;
        for (ComponentValidator validator : validators) {
            StatusCodes statusCodes = validator.validate(context);
            if (statusCodes != null) {
                hasError = true;
            }

            // 节点错误
            boolean updated = updateComponentError(validator);
            if (updated) {
                ComponentDao.update(conn, validator.getComponent());
                writeDbCount++;
            }
        }

这里结合上面的套路

  • 优化stream
  • 修复更新db的bug
finish validate with write 2 db count:0, spend:108427 

这总时间从361s+到108s+,提升非常大。剩余的代码检查没有操作db,但仍然有如此高的耗时,这很不合理。

那么108427 / 6794 约等于16毫秒,校验一个节点需要这么久,显然不正常。
我们再次使用Stopwatch打点状,输出下校验的耗时

        for (ComponentValidator validator : validators) {
            Stopwatch sw = Stopwatch.createStarted();
            StatusCodes statusCodes = validator.validate(context);
            if (statusCodes != null) {
                hasError = true;
            }

            long usedInMillis = sw.elapsed(TimeUnit.MILLISECONDS);
            if (usedInMillis > 20) {
                LOG.debug("[validate usedInMillis:{}]", usedInMillis)
            }
            ....
        }
...
[validate component usedInMillis:199]
...

这就发现了问题,部分节点耗时异常的高。排查后发现这些节点的intent数量很大,intent越大越耗时。

    // 经过排查发现, 在校验User.Intent时会判断是否与数据库已存在的意图重复。
    // 那么这里有很大的计算量:
    // 这样判断扩展问重复从 example.size * db.example.size 
    private boolean intentRepeated() {
        List<String> exampleTexts = getIntents();

        //user的example不能重复,如果是引用的摸板则允许重复
        List<User> users = componets.stream()
                // filter
                .collect(Collectors.toList());

        for (User item : users) {
            List<String> examples =item.getIntents();
            for (String text : examples) {
                if (exampleTexts.contains(text)) {
                    return true;
                }
            }
        }

        return false;
    }

我们来看看这里的问题:

  • 循环中有stream, 这部分在筛选db中的User

    • 这里所有节点都是一样的,不用每次都计算,将结果在context中缓存起来,只计算一次,后续直接用
  • for * for计算量大

    • 修改实现

1、User从context获取,不在这进行计算 - 计算次数减少了6000次+

    List<User> users= null;
    public List<User> getUsers() {
        if (users == null) {
            synchronized (this) {
                if (users == null) {
                    users = componets.stream()
                            // ... filter
                            .collect(Collectors.toList());
                }
            }
        }

        return users;
    }

2、减少计算次数
这里需要一定的经验积累,比如在第二个for循环中,我们改成map.containsKey去判断,就去掉了一个for循环。

  • Tip:在最好的情况下,map.containsKey(key)的时间复杂度时o(1)
  • Context中构建db中存在的Intents,只在第一次用到时计算,其他后续节点直接使用即可;
   
 private boolean intentRepeated() {
       List<String> exampleTexts = getIntents();

       Map<String> exitsIntents = context.intentsMap();
       for (String text : examples) {
            if (exitsIntents.containsKey(text)) {
                    return true;
            }
       }

        return false;
    }

这样以来,我们的计算量变:从 example.size db.example.size 变成 example.size 1 次;

验证:

[start validate with length:6795]
[finish validate with write 2 db count:0, spend:227]

总结:

  • 合理使用stream
  • 缓存结果,减少计算次数
  • 减少计算次数

结论

至此,完成了本次优化,同样数据导入时间控制在了5s左右。

  • 减少db读写次数、批量操作;
  • 合理使用stream,在循环且单个集合数量较小的情况下,使用循环;
  • 合理使用递归
  • 修复Bug
  • 合适使用Context,将结果缓存,减少重复计算
  • 减少计算量

经过此次调整,其中重要的是如何发现问题,解决方案可以有很多。同时要知道调整后,多少是符合预期的,不能盯着小的优化点去弄,这种优化空间太低了,反而会花很多时间效果也不会好。

  • 如何找到瓶颈点
  • 预期优化效果

在开发过程中,我们还是以满足实现为主,在时间可能的情况下合理保证代码效率,可以在后续碰到瓶颈时单独处理即可。

打个广告:

如果你是RASA开发者:

  PromptAI支持通过脑图的方式编辑对话流程,编辑完成后可一键下载Rasa Project文件,可在原生Rasa直接训练[部分高级功能需要配合应用使用] 

 - 功能: FAQ、多轮、变量提取(单个/多个)、form、webhook及自定义Action等高级功能;
 - 编辑: 支持拖拽、回收站、收藏、撤销、重做及复制等便捷功能;
 - 调试: 编辑完成后可一键调试、发布,最大程度减少开发时间。我们提供高性能GPU(3090),让你的效率起飞!
 - 其他: 支持私有部署,如果你有需求可联系我们!

如果你是普通用户,不会开发,但是想用AI对话:
  
 PromptAI对普通用户/企业极度友好,无需了解编程/AI的相关知识,也可快速上手,比Excel使用还简单,没有复杂繁琐的操作及公式! 几分钟就能拥有自己的ChatBot!

免费使用:https://www.promptai.cn

注册过程:免费试用 -> 填写试用信息 -> 审核通过 -> 即可上手!
0

评论 (0)

取消