总结过去几年遇到的一些偶现问题

🖊️总结过去几年遇到的一些偶现问题

🔊偶现问题有一定隐秘性,要有刨根问底的精神,偶现的问题也是问题。

如果上线前不把偶现的问题刨根问底弄清楚,到了线上将会更难排查。

客户所在的上下文环境可能会和我们不同,常常导致我们不能模拟重现问题,在过去的几年中也遇到过不少这样的场景,最近便梳理了一些。

本文结构安排:第一部分罗列场景; 第二部分列举案例。

📚一、场景罗列

偶现问题可以是概率高的,也可以是概率低的; 甚至是出现一次的;或者是一开始是没有,运行一段时间出现的。

大多数问题都是编码不严谨导致,甚至是一些低级错误。

第一类:并发访问、异步编程、资源竞争

偶现问题 具体描述
非线程安全集合类 在多线程情况下,使用非线程安全集合类;在数据量偏大时容易暴露数据安全问题; 在 Java8 stream 直接改成 # parallelStream,未检查集合类等
ThreadLocal 未在每次请求结束时执行 remove 方法,或者因为异常跳过了此方法,同时线程池中的线程被复用,出现数据偶发性问题
修改成员变量 当前线程访问后修改了成员变量,导致其他线程方法在访问该成员变量时不符合预期
异步依赖 异步处理数据;另外一个线程依赖异步的结果;在执行时概率性未获取预期结果值异常
并发 并发修改非安全变量,访问此变量概率性不符合预期异常

第二类:缓存相关,缓存一致性

偶现问题 具体描述
本地 Map 缓存了错误的数据 缓存到 Map 中的数据有异常,导致特定请求触发错误; 比如 null 被存入到 HashMap,使用时触发 NPE
发布引起 redis 数据结构不一致 发布引起的数据结构不一致,导致集群发布时; 未发布完的机器,请求数据会异常;直到发布完所有机器,异常结束
缓存数据失效时间设置过长 缓存时间设置时间过长,部分机器已经更新,部分机器迟迟未更新,表现结果不一致

数据库、本地缓存,分布式缓存数据是常见问题,编码时没有考虑周全,给业务带来麻烦。

缓存不一致性持续的时间极短,往往会忽略缓存一致性这个因素,导致排查方向走偏,增加排查时长,警惕!

第三类:脏数据、数据倾斜

脏数据常常会引起异常现象,也是偶发性问题高发区,此处换成现脏数据易发的场景。

偶现问题 具体描述
未正确使用事务 异常出现脏数据
并发更新数据 如更新丢失
未妥善处理优雅关闭 未妥善处理优雅关闭,导致部分正在执行的任务被直接终止,最后数据异常
分库分表导致数据倾斜 数据倾斜,出现慢 sql,导致超时等
程序运行错误,情况很多 程序运行错误导致;文件被破坏等

脏数据出现触发异常。常见的情况:selectOne,但是查询出来两条。

第四类:边界值、超时、限流

偶现问题 具体描述
上游限流 并发量大,导致上游服务限流,部分请求成功,部分请求失败
阈值限制 触发阈值,比如批量操作,该批次数量超过阈值被限制
特定入参 特定入参导致程序异常
接口超时 请求超时,可能是服务器处理不过来,资源限制;可能是网络抖动等

上游的服务链路很长;异常被转换;日志被吞掉的情况会大大增加排查的难度

第五类:服务器、硬件

偶现问题 具体描述
集群机器发布,未妥善处理机器优雅下线 已经下线的机器还有请求进来;该机器的各种中间件 ip 未移除,导致路由到该节点的请求失败
集群机器中特定机器推送全局配置失败 导致请求到该机器的配置不是最新的,数据不一致,路由到该节点请求数据是旧的或者空的
集群机器中特定机器 hang 住 服务日志太多,导出磁盘满了;或者特定机器 cpu、内存爆了,路由到该节点的请求服务不可用,超时
集群分批发布 未做好升级兼容,数据结构不兼容;各种不兼容;路由到该节点请求异常
灰度策略问题 灰度策略不完善,导致灰度机器,beta机器,线上机器被同一用户随机访问

第六类:程序代码

偶现问题 具体描述
内存泄漏 未正确释放连接资源,比如redis、IO流等资源,导致运行一段时间后,内存泄漏
未处理特殊异常 特殊情况,抛出了异常,但是在调用该方法时没有进行异常处理,可能会导致程序崩溃或出现不可预测的结果;
异常吞掉,没有日志等 特殊情况,部分请求失败了,但是并没有日志;误判代码没有问题
底层中间件 Bug 特殊情况,触发底层中间件的bug。 排查需要专业知识,但攻克后技术会提升一个台阶
线程池任务丢弃 超过任务最大处理能力,直接丢弃任务

程序未做好兼容发布,比如数据结构不兼容,请求参数不兼容,方法不兼容等等;未做好优雅关闭,正在处理的任务被中断。 这样的发布都是灾难。

第七类:网络等其他

偶现问题 具体描述
网络异常 网络延迟、丢包导致请求超时;服务器入口流量限制,大量请求超时
黑白名单 比如 IP 限制,人员黑名单等
检查是否使用代理、VPN软件 本地使用代理,其他人请求可以自己不行的情况

场景暂时列举到这,接下来进入案例环节

📖二、案例描述

非线程安全集合类

并行流里面使用了非线程安全集合类,集合对象返回结果可能不正确。 当数据量小的时候,不容易察觉;当数据量多的时候,容易暴露问题。

JAVA 复制代码
List<XXXDO> dataList = 从 DB 中获取结果集合

// 非线程安全集合了
List<XXXDO> successList = new ArrayList();
List<XXXDO> failList = new ArrayList();

// parallelStream 并行流中使用了不安全的集合
dataList.parallelStream().forEach(
    vo -> {
        .......
        if(执行成功) {
            successList.add(vo);
        } else {
            failList.add(vo);
        }
   }
);

开始为 stream,没有任何问题; 当数据量大的时候,做了一个优化,将 stream 修改成 parallelStream, 测试时,数据量较小,未察觉,线上数据量多的时候,发现了这个问题。

ThreadLocal

当使用 ThreadLocal 时,未正确执行 remove 方法; 有可能是因为抛出异常导致。线程在特殊情况下被复用;导致 ThreadLocal 中的数据符合预期。

注:这是编码不严谨导致。

Java 复制代码
// 正常情况能够执行 remove
try {
    ...
} finally {
    threadLocalUser.remove();
}

不严谨,导致 remove 未执行

Java 复制代码
// 错误使用
try {
    ...
    // 业务异常, 未能执行 remove
    threadLocalUser.remove();
} catch(Exception exception) {
   ...
}

ThreadLocal 其实应用场景很多,但一定要记得移除用完 remove 掉。由于具有线程复用,比较难排查

修改成员变量

从配置中心读取配置信息,该数据作为模板,带有占位符; 在执行实例时,通过上下文参数,解析占位符。比如发送短信、卡片等。

json 复制代码
{
    "authorized":{
        "themeHeader":"交付授权协议",
        "contentDesc":"交付工程师: ($userName$) 向您申请交付权限",
        "keyNote":"特别提醒:如果不签署,交付工程师将无法进行交付",
        "redirectLinkText":"立即前往授权",
        "tenantId":"您所在的组织"$tenantId$"有以下权限申请需要授权"
    }
}

这是一份模板数据,占位符通过上下文替换

Java 复制代码
// 从配置中心读取配置,用成员变量保存
public class CardSceneParamConfig implements XXXDataCallback {

    // 从 nacos 配置读取初始化模板数据
    private Map<String, AuthorizedCardParamVO> cardParamVO = new HashMap<>();
    ......
    // 获取配置模板
    public AuthorizedCardParamVO getAuthorizedCardParamVO(String sceneCode) {
        return cardParamVO.get(sceneCode);
    }
}

// 获取模板对象,修改了模板里面的占位符。
private AuthorizedCardParamVO xxx(AuthorizedCardParamVO stable, Map<String, String> params) {
    ......
    final String contentDescStr = Optional.ofNullable(stable.getContentDesc())
            .map(contentDesc -> contentDesc.replace("$userName$", params.get("userName")))
            .orElse(stable.getContentDesc());

    // 更改了成员变量
    stable.setContentDesc(contentDescStr);
    .......
    return dynamic;
}

stable.setContentDesc(contentDescStr); 修改了成员变量,导致 "contentDesc":"交付工程师: ($userName$) 向您申请交付权限",被修改成具体值 "contentDesc":"交付工程师: (XXXX) 向您申请交付权限"

如果 userName 是同一个人,或者第一次请求到不同机器;都不会有问题;否则有问题。

需要特别注意成员变量被修改的情况。 修改成员变量的案例遇到过很多次。需要警惕。

异步依赖

使用线程池执行,但是将结果添加到 list 这个操作是异步的。有可能代码执行完毕,但是 list 结果集合没有任何的数据。异步依赖。

Java 复制代码
List<XXXDO> dataList = 从 DB 中获取结果集合

// 非线程安全集合
List<XXXDO> successList = new ArrayList();
List<XXXDO> failList = new ArrayList();

for (XXXDO vo : dataList) {
   ThreadUtil.execute(() -> {
        // API 操作 vo
        .......
        if(执行成功) {
            successList.add(vo);
        } else {
            failList.add(vo);
        }  
   });
}
// 可能未获取运行结果就返回了

这是个低级错误,需要异步等待,但是因为数据量小,未察觉这个问题。数据大的时候非常容易暴露。

很久以前,接手了一位离职伙伴的代码,现在想来都觉得很坑。

  1. 上传 excel 数据,到服务端解析,将解析结果上再传到 redis,redis设置 1min 过期;解析这个过程也是一个异步行为。
  2. 客户端上传完成再点击提交数据,从刚才的 redis 取数据再保存到 DB 中。

当数据大的时候,发现一条数据都没有插入到 DB 里面。

原因大致有二:

  • 未解析完成,提交时 redis 还没有数据
  • 提交按钮迟了,redis 解析的数据过期

数据量小的时候不易察觉,因为功能不常用,等数据量大的时候,就暴露了。

并发性修改

下面案例,由于 counter++ 操作不是原子的,同时并发修改。循环的次数偏小,可能不会出现问题。循环次数多 counter 不符合预期

Java 复制代码
public class UnsafeConcurrencyExample {
    private static int counter = 0;

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter++;
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter++;
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Counter: " + counter);
    }
}

数据不一致

当第一次运行这段代码时,会从数据库中获取数据,并将数据放入缓存中。 10 分钟内再次运行代码时,将直接从缓存中获取数据,而不会再次访问数据库。只有当缓存过期后,才会再次从数据库获取新的数据。

JAVA 复制代码
public class CacheExample {
    // 创建缓存
    private static Cache<String, Object> cache = CacheBuilder.newBuilder()
            .expireAfterWrite(10, TimeUnit.MINUTES) // 设置缓存过期时间为10分钟
            .build();

    public static void main(String[] args) {
        String key = "data"; // 缓存的键

        // 从缓存中获取数据,如果缓存中不存在,则从数据库获取
        Object data = cache.get(key, () -> fetchDataFromDB());

        System.out.println("Data: " + data);
    }

    // 模拟从数据库获取数据
    private static Object fetchDataFromDB() {
        // 从数据库获取数据的逻辑
        System.out.println("Fetching data from DB...");
        return "Data from DB";
    }
}

缓存偏长,有部分已经更新,有部分还是旧的,导致数据表现不一致。

数据一致性问题,导致请求到不同服务器节点出现不一样的效果

未考虑优雅关闭

如果提交到线程池的任务,没有考虑优雅关闭,极端情况出现了脏数据,导致偶发性问题。

下面举一个简单的例子,线程池的使用,但是下面线程池未考虑优雅关闭。

JAVA 复制代码
public class SimpleThreadPool {
    private ExecutorService executor;

    public SimpleThreadPool(int threads) {
        executor = Executors.newFixedThreadPool(threads);
    }

    public void execute(Runnable task) {
        executor.execute(task);
    }

    public void shutdown() {
        executor.shutdown();
    }
}

正在使用 execute 执行任务的时候,重新发布,重启、异常中断等等。导致正在执行的任务中断,产生了脏数据

脏数据导致查询结果多条

使用 selectOne 方法查询数据库中的数据,但查询出来多条

com.baomidou.mybatisplus.core.exceptions.MybatisPlusException: One record is expected, but the query result is multiple records] with root cause

边界值触发限流

发生在很多年前的一个事情。需求场景是往一个 IM 群批量发送卡片,由于特定场景,满足场景的卡片数据量较大,大约300,触发了限流。 由于经过了多个服务,导致原始报错,被转换成一个通用异常,也增加了排查的成本。

{"errcode":5001,"errorMsg":"系统错误","success":false}

限流异常错误未考虑,在切面层面统一处理转换成系统异常。

边界值会导致偶发问题,特别是不能模拟客户真实场景,加上原始错误信息丢失时,会增加排查难度。

数据量引发的限流问题较多; 原始错误异常在链路上被转换其他异常也很普遍; 因此在系统里面要多考虑这种场景,增强系统的健壮性。

机器中存在机器异常

  1. 分批发布时,没有做好机器的优雅下线
  2. 节点异常,没有剔除该 IP

由此可能引发以下问题:

  • 下游 RPC 请求异常;该服务的依赖方异常
  • 本机器请求异常
  • mq 消费异常
  • .......

集群健康非常重要!!!!!

因为磁盘打满而出现机器挂了

服务挂掉了 cd /home/adbash: cannot create temp file for here-document: No space left on device

因为集群中的一台机器磁盘满了,hang 住,不能继续服务,路由到这台机器超时异常。其他机器正常可以正常访问。

需要做好集群的检活,异常时及时下掉机器

数据不在同一个事务内

数据不在同一个事务内。比如 updateBalance 是独立事务,在执行时可能出现问题 A 账户余额不够了,导致异常。

Java 复制代码
// 假设这是一个转账操作,从账户A向账户B转账
updateBalance(connection, "B", 100); // 向账户B添加100元
// A账户钱不够了
updateBalance(connection, "A", -100); // 从账户A扣除100元

网络入口带宽不足

发生在小作坊的故事; 在开发阶段,购买了阿里云的服务器,当时网络带宽 1M,测试阶段没有问题,但未压测上线;等用户量上来时,发现一些客户请求总是出现超时,最后排查为网络带宽不足导致。

压测、网络监控非常重要

DDos攻击等导致正常用户异常

存在正常用户异常。带宽资源被抢占了。

rpc 超时

假设客户端发送一个获取用户信息的请求给服务器端,并设置一个超时时间为5秒。客户端期望在5秒内接收到服务器端返回的用户信息。但是由于网络延迟的原因,在某些情况下,服务器端的响应可能会在超时时间之后才到达客户端。

也有可能是因为运行了很长时间,服务端性能出现问题。

内存泄漏

故事发生在多年前,至今印象深刻;是一个 16 台线上机器内存全部飙高的案例。

业务是通过计件算工资;程序是输入表达式运算结果。

服务刚上线, 测试边界值,因为输入一个很大的值,导致类型溢出;是计算工资的方法,程序设置了出错重试。

  • 本来是单例的对象,但是却在每次执行方法时被创建
  • 因为错误发生,这个方法被发送到 mq 进行重试
  • 但是 mq 未设置最大重试次数
  • 因为集群机器都监听这个 mq,导致错误被不断地发送到 mq,形成了死循环。对象被无限创建,导致集群机器内存全部飙高。

历历在目的例子......

✒️三、总结

场景还远远不止上面罗列的这些,但根据这些场景也总结了一些经验

  • 合理的代码编写,很多问题都是编码导致,甚至还有很多低级错误
  • 多考虑边界值,边界值常常因为不会发生而被忽略
  • 合理的日志,方便排查,没有日志的异常增加排查难度
  • 别随便转换异常,做好异常处理
  • 压测,数据大会提前暴露并发相关问题
  • 别吞掉异常,否则出现错误时不容易排查,偶发性问题就变成灵异事件了
  • 机器一定要有完善的监控。包括上下游的监控,否则其中 1 个节点出现问题,整个链路都会因为这个节点出现偶发性的问题。
  • 做好优雅关闭等

很多偶现的问题排查也十分困难,遇到了就是一个很好的训练机会,当排查问题多了,经验就足了,再遇到相似问题就能轻轻搞定了。 像网络问题排查比较麻烦,平时多学习工具,技多不压身。

偶发性问题往往也是由于我们细节做的不够到位!!!

相关推荐
小羊在睡觉7 分钟前
golang定时器
开发语言·后端·golang
pengzhuofan13 分钟前
第10章 Maven
java·maven
用户214118326360218 分钟前
手把手教你在魔搭跑通 DeepSeek-OCR!光学压缩 + MoE 解码,97% 精度还省 10-20 倍 token
后端
追逐时光者23 分钟前
一个基于 .NET 开源、功能强大的分布式微服务开发框架
后端·.net
百锦再1 小时前
Vue Scoped样式混淆问题详解与解决方案
java·前端·javascript·数据库·vue.js·学习·.net
刘一说1 小时前
Spring Boot 启动慢?启动过程深度解析与优化策略
java·spring boot·后端
壹佰大多1 小时前
【spring如何扫描一个路径下被注解修饰的类】
java·后端·spring
百锦再1 小时前
对前后端分离与前后端不分离(通常指服务端渲染)的架构进行全方位的对比分析
java·开发语言·python·架构·eclipse·php·maven
间彧1 小时前
Java双亲委派模型的具体实现原理是什么?
后端
间彧1 小时前
Java类的加载过程
后端