• 使用SpringBoot发送异步事件的方式解决前端接口调用超时问题


    背景

    一个内部使用的系统,要求实现功能:管理员后台一键操作,不定期(举办活动时)批量更新并导出所有普通用户的用户与密码信息为 Excel 表格文件。

    目的是防止时间长了,如果密码不变的话,容易被别人冒用,所以每次不定期的活动开始前,要求重新生成密码。

    密码在数据库中是密文存储,加密算法BCrypt ,在 SpringBoot 中借助 BCryptPasswordEncoder 类实现加密。

    实际场景中操作如下:

    1. 前端页面放一个按钮,用户点击后;
    2. 后端接口先从数据库中查询所有用户;
    3. 排除掉管理员用户;
    4. 循环所有普通用户,生成满足要求的密码,执行加密操作,执行更新数据表操作;
    5. 生成Excel并返回。

    一开始在测试环境下,就十来个用户,这个过程一切正常。导入了实际生产的500+用户数据后,由于前端请求设置的超时时间为10秒,导出用户与密码信息的Excel文件过程超时导致断开连接了,即:这个接口在用户数量稍微多的时候就超过10s了。。

    那么来分析上面的过程,导致效率低的原因可能有:

    1. 在循环中逐个用户去更新密码信息,需要频繁写数据库。
    2. 随机生成8位密码过程可能比较耗时:8至20位,包含大、小写字母、数字、特殊字符_@#$%&*组合。
    3. 对用户密码加密可能比较耗时:BCryptPasswordEncoder。
      @PostMapping("/updatePasswordAndDownload")
        public void updatePasswordAndDownload(HttpServletResponse response,SysUser user){
    
            List<SysUser> list = userService.selectUserList(user);
            List<SysUserExport> userExports = new ArrayList<>();
            String password = null;
            for (SysUser newuser:list){
                //去除超级管理员和系统管理员、审计管理员
                if(!newuser.isAdmin() && !newuser.isSystem() && !newuser.isAudit()) {
                    userService.checkUserAllowed(newuser);
                    userService.checkUserDataScope(newuser.getUserId());
                    //设置8位随机密码并重置存储密码
                    password = PasswordUtil2.getPsw(UserConstants.PASSWORD_MIN_LENGTH); // 导致接口超时,可能的原因2
                    newuser.setPassword(SecurityUtils.encryptPassword(password)); // 导致接口超时,可能的原因3
                    newuser.setUpdateBy(getUsername());
                    userService.resetPwd(newuser); // 导致接口超时,可能的原因1
                    //更新导出用户实体
                    SysUserExport sysUserExport = new SysUserExport();
                    sysUserExport.setPassword(password);
                    sysUserExport.setUserId(newuser.getUserId());
                    sysUserExport.setUserName(newuser.getUserName());
                    sysUserExport.setDept(newuser.getDept().getDeptName());
                    userExports.add(sysUserExport);
                }
            }
            //根据部门排序
            List<SysUserExport> userExportsSorts = userExports.stream().sorted(Comparator.comparing(SysUserExport::getDept).reversed()).collect(Collectors.toList());
            //导出Excel
            ExcelUtil<SysUserExport> util = new ExcelUtil<SysUserExport>(SysUserExport.class);
            util.exportExcel(response, userExportsSorts, "用户数据", "账号密码");
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31

    针对性解决

    原因1:在循环中逐个用户去更新密码信息,需要频繁写数据库。

    针对这个问题,我们能不能不要在循环中每次都去操作数据库,只为更新用户的密码字段;而是用一条 SQL 直接批量更新所有的用户密码呢?

    我们知道在 SQL 中,可以通过 Case When 语句来实现这一需求。那么现在,借助 MyBatis ,我们可以通过以下方法实现对不同用户密码的批量更新:

    	<update id="updatePasswordBatch" parameterType="java.util.List">
    		update sys_user
    		<trim prefix="set" suffixOverrides=",">
    			<trim prefix="password=case" suffix="end,">
    				<foreach collection="list" item="item" index="index">
    					<if test="item.password!=null">
    						when user_id=#{item.userId} then #{item.password}
    					if>
    				foreach>
    			trim>
    		trim>
    		where user_id in
    		<foreach collection="list" index="index" item="item" separator="," open="(" close=")">
    			#{item.userId, jdbcType=BIGINT}
    		foreach>
    	update>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    之后便可以在 for 循环外调用上面这个批量更新用户密码的接口;然而,即使是在循环外调用上面的方法,接口依然超时。。所以,问题不在这个循环更新上。

    原因2:随机生成8位密码过程可能比较耗时:8至20位,包含大、小写字母、数字、特殊字符_@#$%&*组合。

    由于随机生成密码是一个工具方法,我直接单独测试该方法,批量生成500个密码后发现耗时非常短,可以忽略不计,因此,问题也不在这里。。

    原因3:对用户密码加密可能比较耗时:BCryptPasswordEncoder。

    同样,对用户密码加密的方法也是一个工具方法,批量加密500个密码后发现,耗时绝对超过10s,直接不可接受。 BCrypt 加密过程确实慢,但是实际一般都是对一个用户的密码进行加密,不会像我们现在遇到的批量操作。终于,问题找见啦~~

        /**
         * 生成BCryptPasswordEncoder密码
         *
         * @param password 密码
         * @return 加密字符串
         */
        public static String encryptPassword(String password)
        {
            BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
            return passwordEncoder.encode(password);
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    关于 Bcrypt 加密速度慢的问题,我看了 SegmentFault 的一个帖子上有详细说明:Bcrypt加密速度慢是否是鸡肋?

    异步解决方案

    用户密码进行加密肯定是要做的,可是导致接口超时了怎么办?接下来,有请本文的主角闪亮登场,异步事件。

    为减少接口响应时间,在用户点击导出并更新用户密码的按钮后,先设置密码原文,写入到导出的 Excel 文件中响应给前端用户;然后使用 Spring 自带的 ApplicationEventPublisher 发送异步事件,在异步事件监听方法中进行耗时的密码加密与数据表更新操作(这里还考虑到一个前提:用户导出用户名与密码后,这些用户并不会立即使用生成的新密码进行登录,因此异步更新数据表需要花费1-2分钟应该没有大的问题)。

        @Autowired
        private ApplicationEventPublisher applicationEventPublisher;
    
        @PostMapping("/updatePasswordAndDownload")
        public void updatePasswordAndDownload(HttpServletResponse response,SysUser user){
    
            List<SysUser> list = userService.selectUserList(user);
            List<SysUserExport> userExports = new ArrayList<>();
    
            //去除超级管理员和系统管理员、审计管理员
            List<SysUser> collected = list.stream().filter(x -> !x.isAdmin() && !x.isSystem() && !x.isAudit()).collect(Collectors.toList());
    
            for (SysUser newuser : collected){
                userService.checkUserAllowed(newuser);
                userService.checkUserDataScope(newuser.getUserId());
                //设置8位随机密码并重置存储密码
                String password = PasswordUtil2.getPsw(UserConstants.PASSWORD_MIN_LENGTH);
    
                // 为减少接口响应时间,这里先设置密码原文,在异步事件中进行耗时的密码加密与更新操作
                newuser.setPassword(password);
    
                newuser.setUpdateBy(getUsername());
    //                userService.resetPwd(newuser);
    
                //更新导出用户实体
                SysUserExport sysUserExport = new SysUserExport();
                sysUserExport.setPassword(password);
                sysUserExport.setUserId(newuser.getUserId());
                sysUserExport.setUserName(newuser.getUserName());
                sysUserExport.setDept(newuser.getDept().getDeptName());
                userExports.add(sysUserExport);
            }
    
            // 发送事件
            PasswordEvent passwordEvent = new PasswordEvent(this, collected);
            applicationEventPublisher.publishEvent(passwordEvent);
    
            //根据部门排序
            List<SysUserExport> userExportsSorts = userExports.stream().sorted(Comparator.comparing(SysUserExport::getDept).reversed()).collect(Collectors.toList());
            //导出Excel
            ExcelUtil<SysUserExport> util = new ExcelUtil<SysUserExport>(SysUserExport.class);
            util.exportExcel(response, userExportsSorts, "用户数据","投票系统账号密码");
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43

    在事件监听端,通过 @EnableAsync@EventListener 注解实现对异步事件的监听,然后在事件监听器中处理耗时的操作。

    因为实际中的最终用户也就几百个,可直接采用循环逐个更新密码的方式。

    @Component
    @EnableAsync
    public class PasswordListener {
        @Autowired
        private ISysUserService userService;
    
        @EventListener
        @Async
        public void passwordEventHandler(PasswordEvent passwordEvent) {
            // 从事件中获取事件源
            List<SysUser> users = passwordEvent.getMsg();
            System.out.println("监听到PasswordEvent事件");
    
            for (SysUser user : users) {
                user.setPassword(SecurityUtils.encryptPassword(user.getPassword()));
                userService.resetPwd(user);
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    或者采用 MyBatis 的批量更新密码的方式也可以。

    @Component
    @EnableAsync
    public class PasswordListener {
    
        @Autowired
        private ISysUserService userService;
    
        @EventListener
        @Async
        public void passwordEventHandler(PasswordEvent passwordEvent) {
            // 从事件中获取事件源
            List<SysUser> users = passwordEvent.getMsg();
            System.out.println("监听到PasswordEvent事件");
    
            for (SysUser user : users) {
                user.setPassword(SecurityUtils.encryptPassword(user.getPassword()));
    //            userService.resetPwd(user);
            }
    
            // 批量更新用户密码
            userService.updatePasswordBatch(users);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    小总结

    以上便是因接口超时问题引发的原因分析和对应的解决方法,最终采用 Spring 自带的 ApplicationEventPublisher 异步方案解决因用户量增大导致生成密码、加密、导出的超时问题。

    Reference

    https://blog.csdn.net/weixin_44227650/article/details/126408514


    If you have any questions or any bugs are found, please feel free to contact me.

    Your comments and suggestions are welcome!

  • 相关阅读:
    为什么现在西红柿都“硬邦邦”的,放几个星期都不会坏?为你解答
    [机器学习]西瓜书&南瓜书学习(更新中)
    开发中如何防盗链?
    Pytorch 中 tensor的维度拼接
    算法设计与分析复习知识
    Flutter 视频video_player与缓存flutter_cache_manager
    vue根据接口数据配置动态路由(动态配置后台管理系统路由权限)
    docker安装mysql5.7,搭建主从同步,使用mycat实现读写分离
    22服务-ReadDataByIdentifier
    Linux 性能调优之配置CPU调度策略和可调参数
  • 原文地址:https://blog.csdn.net/u013810234/article/details/130903661