一、获取用户 IP 并注入逻辑
在开发用户系统时,我们有时需要知道用户的真实 IP 地址,比如为了记录用户的登录地点。获取 IP 的最佳时机,就是用户刚发起请求的时候,而不是等到后面业务逻辑再来处理。
1. 在拦截器中获取 IP
我们通常会在拦截器(Interceptor)中处理用户请求,比如过去我们在这里提取 token,现在我们可以在同样的位置提取用户 IP。
不过需要注意的是,用户请求经过了 Nginx 转发,不能直接使用 request.getRemoteAddr()
来获取 IP,这样得到的是网关地址,不是真实 IP。
正确做法是让 Nginx 把用户 IP 放到请求头中,比如:
X-Real-IP: 用户真实 IP
我们只要在拦截器中读取这个请求头即可:
String ip = request.getHeader("X-Real-IP");
2. IP 信息暂存到 channel 附件中
拿到 IP 后,我们可以把它存到一个临时存储位置(通常叫做“附件”)中,后面的登录逻辑会用到它。
这里用的是 Netty 的 Channel
对象,我们可以像存储 token 一样,把 IP 放进去:
channel.attr(AttributeKey.valueOf("ip")).set(ip);
这样就把 IP 临时存好了。
3. 登录逻辑中使用 IP 后移除
这个 IP 只在登录逻辑中用一次,用完后就可以从 channel
附件中移除,避免多余数据残留。
小结
IP 读取要从 Nginx 的请求头中拿;
拿到后存到 channel 附件中;
登录用一次后就删掉;
整个流程只做一次,简洁高效。
二、用户上线事件与 IP 信息封装
IP 获取完后,下一步就是记录并封装 IP 信息,以便后续分析用户行为,比如判断是否异地登录、展示登录地点等。
1. 用户上线事件
用户成功登录后,我们会把这个“用户上线”的动作作为一个事件抛出去。为什么要这样做?
简单来说,这样可以解耦,比如登录逻辑专注于验证账号密码,IP 解析等事情可以通过事件异步处理,不影响主流程的速度。
抛出事件的代码很简单(示意):
eventBus.post(new UserOnlineEvent(userId));
2. 创建 IpInfo
实体类
为了更方便管理 IP 数据,我们单独封装了一个 IpInfo
类,用来保存用户的 IP 信息。常见字段有:
createIp
:用户注册时的 IP,只设置一次;updateIp
:用户每次登录时的 IP,会持续更新;其他解析出的归属地信息(如省份、城市、运营商等)。
结构大致如下:
public class IpInfo {
private String createIp;
private String updateIp;
private String province;
private String city;
private String isp;
// ...更多字段
}
3. 设置 IP 的时机
在用户登录成功的方法中,我们就来设置这些 IP 信息。
如果是第一次登录(
createIp
为空),就设置createIp
;不管是不是第一次,都更新
updateIp
。
示意逻辑如下:
if (ipInfo.getCreateIp() == null) {
ipInfo.setCreateIp(currentIp);
}
ipInfo.setUpdateIp(currentIp);
小结
登录成功后抛出“上线事件”,用于异步处理;
创建
IpInfo
类封装 IP 信息;createIp
只记录一次,updateIp
每次都更新。
三、IP 解析框架设计
拿到用户 IP 后,我们希望知道这个 IP 属于哪个省市、运营商。这就需要一个 IP 解析框架 —— 它的任务是根据 IP 地址查出归属地信息,并更新到用户资料中。
这个过程我们设计为异步处理,避免影响登录速度。
1. 在用户上线处理器中触发异步解析
在“用户上线事件”被监听后,我们会触发一个处理器,负责进行 IP 解析。这一步是后台执行的,用户无感知。
为了不阻塞主线程,我们用线程池来异步执行解析任务。
2. 自定义线程池
我们自己定义一个专用线程池,主要考虑:
线程命名明确,方便排查问题;
核心线程数和最大线程数都设置为 1,表示串行处理;
所有任务都用异步方式提交。
示意代码如下:
ExecutorService ipParserPool = new ThreadPoolExecutor(
1, 1, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<>(),
new NamedThreadFactory("ip-parser")
);
提交任务:
ipParserPool.submit(() -> parseIp(userId, currentIp));
3. 是否需要解析的判断逻辑
每次上线都解析 IP 会浪费资源。所以我们先对比:
用户资料中
updateIp
是否和当前 IP 相同;如果一样,就不解析;
如果不一样,说明 IP 变了,就执行解析逻辑。
判断示例:
if (!currentIp.equals(ipInfo.getUpdateIp())) {
// IP 变了,去解析
}
4. 解析 IP 的具体逻辑
解析 IP 主要包括这几步:
(1)重试机制
有时解析服务会不稳定,为了提高成功率,我们设置 最多重试 3 次,每次失败 休眠 2 秒,并打印错误日志。
示意代码:
for (int i = 0; i < 3; i++) {
try {
// 发起解析请求
break; // 成功就退出循环
} catch (Exception e) {
log.warn("解析失败,第 {} 次", i + 1, e);
Thread.sleep(2000);
}
}
(2)发起请求并反序列化
我们使用 Hutool 工具类来发起 HTTP 请求获取 IP 信息:
String response = HttpUtil.get("http://xxx.com/ip?ip=" + currentIp);
拿到返回的数据后,用 JsonUtil 工具类把它转成 Java 对象:
IpInfo info = JsonUtil.toBean(response, IpInfo.class);
最后,把解析结果更新到用户资料中即可。
小结
用户上线后触发异步解析;
解析框架用单线程池,串行执行;
先判断 IP 是否已变,避免重复解析;
最多重试 3 次,每次失败等待 2 秒;
使用 Hutool 发请求,JsonUtil 解析数据,更新用户信息。
四、IP 框架吞吐量测试
我们已经完成了 IP 解析框架的基本逻辑。那接下来就要测试一下它的性能 —— 比如:一秒钟能处理多少个 IP?这就是所谓的吞吐量测试。
1. 为什么要测试?
看看框架能不能扛住大量用户同时登录;
帮助我们找到性能瓶颈,是否需要优化;
为未来扩容做准备。
2. 怎么测?
我们写一个简单的测试方法,做两件事:
循环调用解析方法,比如连续解析 10 次;
记录开始时间和结束时间,算出每次大概需要多久。
示意代码如下:
long start = System.currentTimeMillis();
for (int i = 0; i < 10; i++) {
try {
parseIp(userId, testIp); // 调用 IP 解析方法
System.out.println("第 " + (i + 1) + " 次解析成功:" + System.currentTimeMillis());
} catch (Exception e) {
e.printStackTrace();
}
}
long end = System.currentTimeMillis();
System.out.println("总耗时:" + (end - start) + "ms");
测试结果显示:差不多每秒解析 1 个 IP,当然这也跟你网络、第三方服务快慢有关。
3. 测试中的异常处理
在测试过程中,如果解析失败,我们不希望影响整个流程。
所以加上简单的异常捕获,一旦失败,就返回 null
,方便继续测试:
try {
return parseIp(ip);
} catch (Exception e) {
return null;
}
4. 优雅停机:关闭线程池
测试完毕后,别忘了关闭线程池,不然程序可能无法正常退出。
步骤如下:
调用
shutdown()
,不再接受新任务;设置最大等待时间,比如 10 秒;
如果还没完成,强制关闭。
示意代码:
ipParserPool.shutdown();
if (!ipParserPool.awaitTermination(10, TimeUnit.SECONDS)) {
ipParserPool.shutdownNow();
}
小结
吞吐量测试可以估算框架性能;
循环调用解析方法,记录时间;
异常要处理,不能中断整个测试;
测试后要关闭线程池,程序才能优雅停机。
五、线程池优雅停机
我们在 IP 解析中使用了线程池来异步执行任务。程序运行时一切正常,但如果线程池不关闭,程序可能无法退出或者资源一直占用。
所以我们需要在系统关闭时,把线程池优雅地停掉。
1. 什么是“优雅停机”?
优雅停机的意思是:
不再接受新的任务;
把正在执行的任务做完;
给任务一点时间完成;
超时没完成,就强制停止。
这样既不会浪费资源,也不会留下“未完成的事情”。
2. 停机代码怎么写?
假设我们的线程池叫 ipParserPool
,优雅停机步骤如下:
// 1. 通知线程池:别接新任务了
ipParserPool.shutdown();
// 2. 等最多 10 秒,看看线程池能不能处理完现有任务
if (!ipParserPool.awaitTermination(10, TimeUnit.SECONDS)) {
// 3. 如果超过 10 秒还没停下来,那就强制关闭
ipParserPool.shutdownNow();
}
解释一下:
shutdown()
:相当于说“停止接活”;awaitTermination()
:最多等 10 秒,看有没有彻底停下来;shutdownNow()
:如果还不行,直接打断所有线程,强制停机。
3. 这个操作放在哪?
一般放在项目的 @PreDestroy
方法或 Spring 的销毁钩子里。比如:
@PreDestroy
public void destroy() {
// 上面的优雅停机代码就写在这里
}
Spring 容器关闭时,就会自动调用这个方法。
小结
用了线程池,一定要在项目关闭时优雅停机;
做法是先
shutdown()
,再awaitTermination()
,最后shutdownNow()
;推荐把停机逻辑写在
@PreDestroy
方法中,自动触发。