🖊️总结过去几年遇到的一些偶现问题
🔊偶现问题有一定隐秘性,要有刨根问底的精神,偶现的问题也是问题。
如果上线前不把偶现的问题刨根问底弄清楚,到了线上将会更难排查。
客户所在的上下文环境可能会和我们不同,常常导致我们不能模拟重现问题,在过去的几年中也遇到过不少这样的场景,最近便梳理了一些。
本文结构安排:第一部分罗列场景; 第二部分列举案例。
📚一、场景罗列
偶现问题可以是概率高的,也可以是概率低的; 甚至是出现一次的;或者是一开始是没有,运行一段时间出现的。
大多数问题都是编码不严谨导致,甚至是一些低级错误。
第一类:并发访问、异步编程、资源竞争
偶现问题 | 具体描述 |
---|---|
非线程安全集合类 | 在多线程情况下,使用非线程安全集合类;在数据量偏大时容易暴露数据安全问题; 在 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);
}
});
}
// 可能未获取运行结果就返回了
这是个低级错误,需要异步等待,但是因为数据量小,未察觉这个问题。数据大的时候非常容易暴露。
很久以前,接手了一位离职伙伴的代码,现在想来都觉得很坑。
- 上传 excel 数据,到服务端解析,将解析结果上再传到 redis,redis设置 1min 过期;解析这个过程也是一个异步行为。
- 客户端上传完成再点击提交数据,从刚才的 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}
限流异常错误未考虑,在切面层面统一处理转换成系统异常。

边界值会导致偶发问题,特别是不能模拟客户真实场景,加上原始错误信息丢失时,会增加排查难度。
数据量引发的限流问题较多; 原始错误异常在链路上被转换其他异常也很普遍; 因此在系统里面要多考虑这种场景,增强系统的健壮性。
机器中存在机器异常

- 分批发布时,没有做好机器的优雅下线
- 节点异常,没有剔除该 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 个节点出现问题,整个链路都会因为这个节点出现偶发性的问题。
- 做好优雅关闭等
很多偶现的问题排查也十分困难,遇到了就是一个很好的训练机会,当排查问题多了,经验就足了,再遇到相似问题就能轻轻搞定了。 像网络问题排查比较麻烦,平时多学习工具,技多不压身。
偶发性问题往往也是由于我们细节做的不够到位!!!