8.4 KiB
8.4 KiB
UserAgent解析死锁修复总结
问题描述
生产环境出现假死现象,接口连接超时。通过线程堆栈分析发现死锁问题:
死锁链条
线程 http-nio-28080-exec-9 (持有锁)
├─ 持有: ConcurrentHashMap锁 (Caffeine缓存)
├─ 正在执行: UserAgentAnalyzer.parse() → initializeMatchers()
└─ 等待: Logback日志锁
线程 http-nio-28080-exec-1, exec-2 (等待锁)
├─ 等待: ConcurrentHashMap锁
└─ 阻塞在: UserAgentAnalyzer.parse()
多个线程 (Scheduler Workers)
├─ 等待: Logback日志锁
└─ 阻塞在: 日志输出
根本原因
UserAgent解析库(yauaa)在持有缓存锁的情况下,内部调用了日志输出,违反了"持有锁时不应该调用外部方法"的原则。当日志系统繁忙时,就会形成死锁。
解决方案
方案1:异步解析 + 超时降级(已实施)
实施日期:2025-12-29
核心思路:
- 异步解析:使用独立线程池异步解析UserAgent
- 超时控制:200ms超时,避免长时间阻塞
- 降级策略:超时返回默认值"Unknown"
- 隔离风险:独立线程池,不影响主业务流程
方案2:更换解析库为Browscap Java(已实施)
实施日期:2025-12-29
核心思路:从yauaa更换为Browscap Java,彻底解决日志依赖问题。
Browscap Java优势:
- 性能更好:解析速度约2ms(yauaa约10-50ms)
- 活跃维护:定期更新浏览器数据库
- 无日志依赖:不会触发日志死锁问题
- 线程安全:内部实现更加健壮
依赖变更:
<!-- 旧依赖(已移除) -->
<!-- <dependency>
<groupId>nl.basjes.parse.useragent</groupId>
<artifactId>yauaa</artifactId>
<version>7.26.1</version>
</dependency> -->
<!-- 新依赖 -->
<dependency>
<groupId>com.blueconic</groupId>
<artifactId>browscap-java</artifactId>
<version>1.4.3</version>
</dependency>
技术实现细节
修改文件清单
1. pom.xml - 依赖更换
变更内容:
- 移除yauaa依赖
- 添加browscap-java 1.4.3依赖
2. UserAgentUtil.java - 解析器更换
变更内容:
- 导入包从yauaa改为Browscap Java
- 初始化方式改为
UserAgentService().loadParser() - 解析方法改为使用
Capabilities对象 - 保留异步解析机制(200ms超时)
关键代码变更:
// 旧代码(yauaa)
private static final UserAgentAnalyzer USER_AGENT_ANALYZER =
UserAgentAnalyzer.newBuilder()
.withCache(10000)
.build();
// 新代码(Browscap Java)
private static final UserAgentParser USER_AGENT_PARSER;
static {
try {
USER_AGENT_PARSER = new UserAgentService().loadParser();
log.info("Browscap UserAgent解析器初始化成功");
} catch (IOException | ParseException e) {
log.error("Browscap UserAgent解析器初始化失败", e);
throw new RuntimeException("Failed to initialize Browscap UserAgent parser", e);
}
}
解析方法变更:
// 旧代码(yauaa)
UserAgent userAgent = USER_AGENT_ANALYZER.parse(userAgentString);
String browser = userAgent.getValue("AgentName");
String version = userAgent.getValue("AgentVersion");
// 新代码(Browscap Java)
Capabilities capabilities = USER_AGENT_PARSER.parse(userAgentString);
String browser = capabilities.getBrowser();
String version = capabilities.getBrowserMajorVersion();
String platform = capabilities.getPlatform();
String platformVersion = capabilities.getPlatformVersion();
3. ThreadPoolConfig.java - 线程池配置(保持不变)
UserAgent解析专用线程池配置保持不变:
@Bean("userAgentParseExecutor")
public AsyncTaskExecutor userAgentParseExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2); // 核心线程数
executor.setMaxPoolSize(5); // 最大线程数
executor.setQueueCapacity(100); // 队列容量
executor.setThreadNamePrefix("useragent-parse-");
executor.setKeepAliveSeconds(60);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(10);
executor.initialize();
return executor;
}
配置说明:
- 核心线程2个,最大5个(解析任务不多)
- 队列100(缓冲并发登录)
- 拒绝策略:CallerRunsPolicy(降级到同步解析)
- 超时时间:200ms(快速失败)
2. UserAgentUtil.java
改造为异步解析:
/**
* 异步解析User-Agent信息(带超时控制)
*
* ⚠️ 推荐使用此方法,避免死锁
*/
public UserAgentInfo parseUserAgentAsync(String userAgentString) {
if (userAgentString == null || userAgentString.trim().isEmpty()) {
return UserAgentInfo.unknown();
}
try {
// 异步解析,带超时控制
CompletableFuture<UserAgentInfo> future = CompletableFuture.supplyAsync(
() -> parseUserAgentSync(userAgentString),
userAgentParseExecutor
);
// 等待结果,最多200ms
return future.get(PARSE_TIMEOUT_MS, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
// 超时降级:返回Unknown
log.warn("UserAgent解析超时({}ms),返回默认值: {}", PARSE_TIMEOUT_MS, userAgentString);
return UserAgentInfo.unknown();
} catch (Exception e) {
// 其他异常降级:返回Unknown
log.warn("UserAgent解析失败,返回默认值: {}", userAgentString, e);
return UserAgentInfo.unknown();
}
}
关键特性:
- 使用CompletableFuture异步执行
- 200ms超时控制
- 超时/异常自动降级
- 原同步方法标记为@Deprecated
3. JwtTokenUtil.java
调整调用方式:
/**
* 存储Token到Redis(包含登录时间和请求信息)
*
* 🔧 死锁修复:使用异步UserAgent解析
*/
private void storeToken(Long userId, String token, HttpServletRequest request) {
String key = TOKEN_PREFIX + userId;
// 获取IP地址
String ipAddress = userAgentUtil.getRealIpAddress(request);
// 异步解析User-Agent(带超时控制,避免死锁)
String userAgentString = request.getHeader("User-Agent");
UserAgentUtil.UserAgentInfo userAgentInfo = userAgentUtil.parseUserAgentAsync(userAgentString);
// 存储Token + 登录时间 + 请求信息
Map<String, Object> tokenInfo = new HashMap<>();
tokenInfo.put("token", token);
tokenInfo.put("loginTime", LocalDateTime.now().toString());
tokenInfo.put("ipAddress", ipAddress);
tokenInfo.put("browser", userAgentInfo.getBrowser());
tokenInfo.put("os", userAgentInfo.getOs());
redisUtil.set(key, tokenInfo, expiration);
}
效果验证
编译测试
mvn clean compile -DskipTests
✅ 编译成功,无错误
预期效果
- 消除死锁:异步解析避免持有锁时写日志
- 快速响应:200ms超时,不影响登录速度
- 优雅降级:超时返回"Unknown",不影响功能
- 隔离风险:独立线程池,不影响其他业务
监控指标
建议监控以下指标:
- UserAgent解析超时次数
- UserAgent解析平均耗时
- 线程池队列长度
- 线程池拒绝次数
部署建议
1. 灰度发布
建议先在测试环境验证,然后灰度发布到生产环境:
- 第一批:10%流量
- 第二批:50%流量
- 第三批:100%流量
2. 回滚方案
如果出现问题,可以快速回滚到旧版本。修改是向后兼容的,不影响数据结构。
3. 监控告警
配置以下告警:
- UserAgent解析超时率 > 10%
- 线程池队列满
- 线程池拒绝次数 > 0
后续优化建议
短期优化
- 调整超时时间:根据实际情况调整200ms超时
- 预热缓存:应用启动时预热常见UserAgent
- 监控优化:添加详细的监控指标
长期优化
- 更换解析库:考虑使用更轻量级的UserAgent解析库
- 缓存优化:使用Redis缓存解析结果
- 异步更新:登录时先返回默认值,后台异步更新
相关文档
- 线程堆栈分析:
thread_dump_20251229_091524.txt - 问题分析文档:
TASK_OKHTTP_CONNECTION_LEAK.md
修改日期
2025-12-29
修改人
Kiro AI Assistant