news 2026/10/19 3:24:07

Java后台获取访问者真实外网IP及省市信息完整方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java后台获取访问者真实外网IP及省市信息完整方案

1. 从一次线上排查说起:为什么后台必须拿到真实的外网IP

做过Web后端的朋友大概率都遇到过这样的场景:运营同学跑过来问,“今天这个活动页面的访问量里,某个省份的用户占比是多少?”或者风控同学说,“有个账号行为异常,帮我看看他最近登录的IP都在哪些城市。”这时候你打开日志一看,request.getRemoteAddr()打出来的全是127.0.0.1或者192.168.x.x这种内网地址,根本没法用。

这不是代码写错了,而是请求经过了反向代理、负载均衡或者网关层,Tomcat拿到的其实是上一层代理的地址。Java后台获取访问者外网IP、省和市信息这件事,看起来简单,实际上从“拿到真实IP”到“解析出省市”中间有好几个坑。我前后在几个项目里都踩过一遍,今天把完整的思路和代码整理出来,给需要做用户地域统计、风控、内容分发或者日志分析的同学一个可以直接抄的参考。

这篇文章适合谁看?如果你是有一定Java Web基础,做过Spring Boot项目,但对HTTP代理头、IP库选型、内网地址判断这些细节不太清楚的开发者,那正好。我会从请求链路讲起,把每一层的原理说透,再给出可运行的代码,最后把常见问题和避坑经验一并列出来。整套方案不依赖任何特定云厂商,本地部署和容器环境都能用。

2. 整体方案设计:从请求到省市,分几步走

2.1 核心链路拆解

整个流程可以拆成三个独立的阶段,每个阶段解决一个明确的问题:

  • 阶段一:拿到真实的外网IP。请求从用户浏览器出发,可能经过CDN、Nginx、网关、负载均衡,最后才到你的Java应用。每一层都可能往HTTP头里塞自己的地址,你要做的就是从这些头里找出最原始的那个客户端IP。
  • 阶段二:判断这个IP是不是有效的外网IP。拿到一个IP字符串之后,不能直接用,因为可能是内网地址、回环地址、甚至是被伪造的非法字符串。必须先做合法性校验和内外网判断。
  • 阶段三:用IP去查省市信息。这一步需要一个IP地址库,把IP段映射到地理位置。选型上有离线库和在线接口两条路,各有取舍。

这三个阶段是串行的,前一步的输出是后一步的输入。很多教程把这三步混在一起写,导致代码又长又难维护。我建议在项目里拆成独立的工具类,各司其职。

2.2 为什么不能只用 getRemoteAddr

先解释一个最基础的原理。当浏览器直接连你的Tomcat时,request.getRemoteAddr()返回的就是用户的真实IP。但生产环境几乎不可能让Tomcat裸奔,前面一定有一层Nginx或者云负载均衡。

这时候TCP连接是代理服务器和Tomcat建立的,getRemoteAddr()拿到的是代理的内网IP。代理服务器在转发请求时,通常会把原始客户端IP写进X-Forwarded-For这个HTTP头里。所以你要读的是头,而不是getRemoteAddr()。

但问题来了,X-Forwarded-For是可以被客户端伪造的。如果代理层没有做清理,恶意用户完全可以自己塞一个假的IP进去。所以读取头的顺序和信任策略非常关键,这个后面会详细讲。

2.3 省市解析的两种技术路线对比

对比维度离线IP库在线IP查询接口
响应速度微秒级,内存直接查几十到几百毫秒,受网络影响
准确度依赖库的更新频率通常较新,但各家不一
成本一次性采购或使用免费库按调用量计费或有免费额度
隐私合规数据不出内网,更可控IP会发送到第三方
适用场景高并发、大批量、内网环境低频调用、快速验证

我的建议是:生产环境优先用离线库。原因很直接,用户每次请求都要查一次地域,如果走在线接口,QPS一上来光网络延迟就扛不住,而且第三方接口挂了你的业务也跟着挂。离线库虽然需要定期更新,但稳定性和性能完全不是一个量级。

3. 第一步:正确获取真实外网IP的完整实现

3.1 理解代理头:X-Forwarded-For 和 X-Real-IP

这两个头是最常见的,先搞清楚它们的区别。

X-Forwarded-For是一个逗号分隔的列表,格式是这样的:

X-Forwarded-For: 客户端IP, 代理1的IP, 代理2的IP

每经过一层代理,代理就把上一跳的地址追加到末尾。所以列表的第一个才是最初的客户端IP,但前提是这条链路没有被伪造。

X-Real-IP通常只包含一个IP,是Nginx这类代理直接设置的,一般就是直连的客户端地址。它比X-Forwarded-For更简洁,但只有一层代理时才有意义。

除了这两个,还有一些不太常见的头,比如Proxy-Client-IP、WL-Proxy-Client-IP(WebLogic用的)、HTTP_CLIENT_IP、HTTP_X_FORWARDED_FOR。这些在不同容器和代理组合下会出现,写工具类的时候最好都覆盖到。

3.2 代码实现:一个健壮的IP获取工具类

下面这个工具类是我在多个项目里迭代出来的版本,核心逻辑是:按优先级依次检查各个头,取到第一个合法的公网IP就返回。

import javax.servlet.http.HttpServletRequest; public class IpUtils { private static final String UNKNOWN = "unknown"; private static final String LOCALHOST_IPV4 = "127.0.0.1"; private static final String LOCALHOST_IPV6 = "0:0:0:0:0:0:0:1"; /** * 获取客户端真实IP * 优先级:X-Forwarded-For > X-Real-IP > Proxy-Client-IP > getRemoteAddr */ public static String getRealIp(HttpServletRequest request) { String ip = null; // 依次尝试各个代理头 String[] headers = { "X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP", "HTTP_CLIENT_IP", "HTTP_X_FORWARDED_FOR" }; for (String header : headers) { ip = request.getHeader(header); if (isValidIp(ip)) { break; } } // 所有头都没取到,回退到 getRemoteAddr if (!isValidIp(ip)) { ip = request.getRemoteAddr(); } // X-Forwarded-For 可能是列表,取第一个 if (ip != null && ip.contains(",")) { ip = ip.split(",")[0].trim(); } // 处理 IPv6 回环地址 if (LOCALHOST_IPV6.equals(ip)) { ip = LOCALHOST_IPV4; } return ip; } private static boolean isValidIp(String ip) { return ip != null && ip.length() > 0 && !UNKNOWN.equalsIgnoreCase(ip); } }

这段代码有几个细节值得说。第一,X-Forwarded-For取到之后一定要按逗号分割取第一个,因为后面跟的都是代理地址。第二,isValidIp只做了非空和 unknown 判断,真正的合法性校验放在下一步。第三,IPv6的回环地址0:0:0:0:0:0:0:1要单独处理,否则后面判断内网的时候会漏掉。

3.3 内网IP判断:哪些地址不能用来查省市

拿到IP之后,必须先过滤掉内网地址。原因很简单,内网IP在IP库里查不到有意义的地理位置,而且如果代理头被伪造,攻击者可能塞一个内网地址来绕过风控。

内网地址的范围是固定的,RFC 1918定义了三个段:

  • 10.0.0.0/8:10开头的所有地址
  • 172.16.0.0/12:172.16 到 172.31
  • 192.168.0.0/16:192.168开头的所有地址

另外还有几个特殊地址也要排除:127.0.0.0/8回环地址、169.254.0.0/16链路本地地址、0.0.0.0、255.255.255.255广播地址。

public static boolean isInnerIp(String ip) { if (ip == null || ip.isEmpty()) { return true; } // 先做格式校验 if (!isValidIpFormat(ip)) { return true; } long ipNum = ipToLong(ip); // 10.0.0.0/8 if (ipNum >= ipToLong("10.0.0.0") && ipNum <= ipToLong("10.255.255.255")) { return true; } // 172.16.0.0/12 if (ipNum >= ipToLong("172.16.0.0") && ipNum <= ipToLong("172.31.255.255")) { return true; } // 192.168.0.0/16 if (ipNum >= ipToLong("192.168.0.0") && ipNum <= ipToLong("192.168.255.255")) { return true; } // 127.0.0.0/8 if (ipNum >= ipToLong("127.0.0.0") && ipNum <= ipToLong("127.255.255.255")) { return true; } // 169.254.0.0/16 if (ipNum >= ipToLong("169.254.0.0") && ipNum <= ipToLong("169.254.255.255")) { return true; } return false; } public static long ipToLong(String ip) { String[] parts = ip.split("\\."); long result = 0; for (int i = 0; i < 4; i++) { result |= Long.parseLong(parts[i]) << (24 - (8 * i)); } return result; }

把IP转成long再比较,比字符串比较高效得多,也避免了192.168.1.100和192.168.1.9这种字符串比较出错的坑。这个转换逻辑是IP解析的基础,后面查库的时候也会用到。

注意:如果你的应用部署在容器里,Pod的IP可能是172.17.x.x这种,也属于内网范围。所以判断内网的时候要结合你的实际网络规划,别把合法的代理地址误判了。

4. 第二步:IP地址库选型与省市解析实现

4.1 离线库选型:免费方案和商业方案怎么选

离线IP库的选择直接决定了省市解析的准确度。市面上主流的方案有这么几类:

纯真IP库(QQWry)是最老牌的免费库,格式是dat文件,社区维护。优点是免费、体积小(几MB)、解析速度快。缺点是更新不够及时,新分配的IP段可能查不到,而且省市信息有时候只到省级。

GeoLite2是MaxMind提供的免费库,有City和Country两个版本。City版本能精确到城市,格式是mmdb,官方提供Java SDK。缺点是免费版准确度比商业版低一些,尤其是国内IP,城市级别的准确率大概在70%到80%。

商业IP库比如一些国内厂商提供的高精度库,准确率能到95%以上,支持街道级别,但需要付费,而且通常按年授权。

我的实际经验是:如果只是做省级统计,GeoLite2免费版够用;如果要精确到城市做风控,建议上商业库或者用多个库交叉验证。下面我用GeoLite2来演示,因为它是免费方案里综合表现最好的。

4.2 引入依赖和加载数据库文件

GeoLite2的Java SDK用起来很简单,Maven依赖如下:

<dependency> <groupId>com.maxmind.geoip2</groupId> <artifactId>geoip2</artifactId> <version>4.2.0</version> </dependency>

数据库文件GeoLite2-City.mmdb需要单独下载,放到项目的resources目录下。注意这个文件有几十MB,不建议打进jar包,更好的做法是放在服务器固定路径,启动时加载。

import com.maxmind.db.CHMCache; import com.maxmind.geoip2.DatabaseReader; import com.maxmind.geoip2.model.CityResponse; import com.maxmind.geoip2.record.*; import java.io.File; import java.net.InetAddress; public class IpLocationService { private DatabaseReader reader; public void init() throws Exception { File database = new File("/data/ipdb/GeoLite2-City.mmdb"); reader = new DatabaseReader.Builder(database) .withCache(new CHMCache()) .build(); } public Location query(String ip) { try { InetAddress address = InetAddress.getByName(ip); CityResponse response = reader.city(address); Country country = response.getCountry(); Subdivision subdivision = response.getMostSpecificSubdivision(); City city = response.getCity(); return new Location( country.getIsoCode(), country.getName(), subdivision.getName(), city.getName() ); } catch (Exception e) { // 查不到就返回空,不要抛异常影响主流程 return Location.EMPTY; } } }

这里有个关键点:DatabaseReader是线程安全的,全局只需要一个实例,不要每次请求都new一个,那样会频繁读文件,性能极差。用CHMCache做缓存能显著提升重复IP的查询速度。

4.3 中文省市名称的处理

GeoLite2返回的省市名称默认是英文的,比如Beijing、Guangdong。如果你的业务需要中文,有两个办法:

一是用locale参数指定语言,但GeoLite2对中文的支持有限,很多城市名还是英文。二是自己维护一张映射表,把英文名转成中文。我一般用第二种,因为可控性强。

private static final Map<String, String> PROVINCE_MAP = new HashMap<>(); static { PROVINCE_MAP.put("Beijing", "北京市"); PROVINCE_MAP.put("Shanghai", "上海市"); PROVINCE_MAP.put("Guangdong", "广东省"); PROVINCE_MAP.put("Zhejiang", "浙江省"); // ... 其他省份 } public String getProvinceCn(String enName) { return PROVINCE_MAP.getOrDefault(enName, enName); }

映射表不用一次性写全,用到哪个补哪个。实际项目里我建议把这张表放到配置文件或者数据库里,方便运营同学维护,不用改代码重新发版。

4.4 性能优化:缓存和异步

如果QPS比较高,每次请求都查库还是会有开销。两个优化手段:

本地缓存:用Caffeine或者Guava Cache,把IP到省市的映射缓存起来,设置合理的过期时间(比如1小时)。同一个用户短时间内多次请求,第二次直接走缓存。

LoadingCache<String, Location> locationCache = Caffeine.newBuilder() .maximumSize(100_000) .expireAfterWrite(1, TimeUnit.HOURS) .build(ip -> ipLocationService.query(ip));

异步查询:如果省市信息不是接口返回的必需字段,可以丢到线程池里异步查,不阻塞主流程。比如只用于日志记录的场景,完全没必要同步等结果。

提示:缓存key直接用IP字符串就行,不用做额外处理。注意缓存穿透问题,查不到的IP也要缓存一个空值,否则恶意IP会反复查库。

5. 第三步:完整实操流程与关键配置

5.1 项目结构规划

我习惯把这块功能拆成三个类,职责清晰:

com.example.ip ├── IpUtils.java // IP获取和校验 ├── IpLocationService.java // 省市查询 └── Location.java // 结果实体

IpUtils只负责从request里拿IP、判断内外网,不依赖任何IP库。IpLocationService只负责查库,输入IP输出Location。这样拆分的好处是,如果以后换IP库,只需要改IpLocationService,其他代码不动。

5.2 在Spring Boot中集成

用一个拦截器或者过滤器,在请求进入Controller之前把IP和省市信息解析好,塞到ThreadLocal或者request attribute里,后续业务代码直接取。

@Component public class IpResolveInterceptor implements HandlerInterceptor { @Autowired private IpLocationService locationService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String ip = IpUtils.getRealIp(request); request.setAttribute("clientIp", ip); if (!IpUtils.isInnerIp(ip)) { Location location = locationService.query(ip); request.setAttribute("clientLocation", location); } return true; } }

注册拦截器的时候注意排除静态资源路径,不然每个css、js请求都查一次库,纯属浪费。

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(ipResolveInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/health", "/api/public/**"); } }

5.3 Nginx层的配合配置

后端代码写得再对,如果Nginx没配好,X-Forwarded-For里拿到的还是代理IP。Nginx的标准配置是这样的:

location /api/ { proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }

$proxy_add_x_forwarded_for这个变量的作用是:如果请求里已经有X-Forwarded-For,就追加当前$remote_addr;如果没有,就新建一个。这样后端拿到的列表第一个就是最原始的客户端IP。

但这里有个安全隐患:如果客户端自己伪造了一个X-Forwarded-For头,Nginx会把它保留下来并追加,后端取第一个就取到了假IP。解决办法是在最外层代理把客户端的头清掉:

proxy_set_header X-Forwarded-For $remote_addr;

这样不管客户端传什么,都被覆盖成真实的直连地址。只有你完全信任的代理层才能用$proxy_add_x_forwarded_for。

5.4 参数计算:IP转long的位运算过程

前面ipToLong那段代码,很多人第一次看不太明白,我拆开讲一下。一个IPv4地址由4个0到255的数字组成,每个数字占8位,总共32位。比如192.168.1.100:

  • 192 左移24位:192 << 24
  • 168 左移16位:168 << 16
  • 1 左移8位:1 << 8
  • 100 不动

四个结果做按位或运算,就得到一个32位的long值。这样比较大小就等价于比较IP的数值大小,范围判断变得非常简单。这个技巧在IP库的二分查找里也是核心,因为IP段本质上是两个long值之间的区间。

6. 常见问题与排查技巧实录

6.1 问题速查表

现象可能原因排查方向解决方案
拿到的IP是127.0.0.1请求没经过代理,或代理没设置头检查Nginx配置确认proxy_set_header配置正确
拿到的是内网IP代理头被覆盖或缺失打印所有请求头检查代理链路,确认信任层
省市查出来是空的IP库没覆盖该IP段用IP库自带的测试工具验证更新库文件或换商业库
省市信息是英文没做本地化映射检查返回的locale维护中英文映射表
查询很慢每次new DatabaseReader看是否有频繁IO全局单例+缓存
城市定位不准免费库精度有限对比多个库的结果升级商业库或交叉验证

6.2 独家避坑经验

坑一:X-Forwarded-For 的第一个IP不一定是真的。我遇到过一次,攻击者伪造了X-Forwarded-For: 1.2.3.4,而我们的Nginx用的是$proxy_add_x_forwarded_for,结果后端取到的就是1.2.3.4。后来改成最外层强制覆盖才解决。所以信任边界这个概念很重要:只有你完全控制的代理层写入的头才能信,客户端传进来的一律不可信。

坑二:IPv6地址会让你的内网判断失效。现在很多网络环境是双栈的,用户可能通过IPv6访问。如果你的isInnerIp只处理了IPv4,IPv6地址会走到ipToLong里直接抛异常。要么在入口处把IPv6转成IPv4(如果是IPv4映射地址),要么单独处理IPv6的内网段(fc00::/7、fe80::/10)。

坑三:GeoLite2的库文件要定期更新。IP地址的分配是动态变化的,一个库用半年以上准确率会明显下降。MaxMind每月更新一次,建议写个定时任务自动下载。但注意更新的时候要热替换,别直接覆盖正在使用的文件,否则查询会报错。可以先下载到临时文件,再原子性地重命名。

坑四:容器环境下 getRemoteAddr 拿到的是Pod IP。如果你的应用跑在K8s里,前面有Ingress,getRemoteAddr()拿到的是Ingress Controller的Pod IP,通常是10.x或172.x开头。这时候必须依赖X-Forwarded-For,而且要确认Ingress有没有正确传递这个头。不同Ingress Controller的默认行为不一样,部署前一定要验证。

坑五:省市信息不要用于精确风控。IP定位的精度有限,同一个城市的不同区可能共用一个IP段,甚至运营商的动态IP会让用户的位置在几个城市之间跳。所以IP省市只能作为辅助信号,不能作为唯一的风控依据。我见过有团队用IP城市判断异地登录,结果误伤了一大批用移动网络的用户。

6.3 测试验证方法

写完代码怎么验证?几个实用手段:

  • 本地测试:用curl带上自定义头模拟代理请求,curl -H "X-Forwarded-For: 114.114.114.114" http://localhost:8080/api/test,看后端解析出来的IP和省市对不对。
  • 在线对照:拿几个已知IP,去公开的IP查询网站对照结果,验证你的库准确度。
  • 边界测试:专门测内网IP、回环IP、非法字符串、超长字符串,确保不会抛异常。
  • 压测:用JMeter或者wrk压一下,看加了IP解析之后QPS下降多少。如果下降超过10%,说明缓存没做好。

7. 几个可以立刻用上的扩展思路

省市信息拿到之后,能做的事情比想象中多。我在项目里实际用过的几个场景:

地域维度的访问统计。把省市信息写进日志,用ELK或者ClickHouse做聚合,就能看到每个省份的访问量、转化率。比传统的PV/UV维度丰富得多,运营同学特别喜欢。

内容本地化。根据用户所在城市,首页推荐不同的内容。比如天气应用、本地生活服务,这个是最直接的应用。

简单的风控规则。比如注册IP和登录IP跨省且时间间隔很短,可以触发二次验证。但前面说了,只能作为辅助信号。

CDN回源优化。虽然CDN本身有调度,但如果你自建了多机房,可以根据用户IP做就近接入。

最后分享一个我在实际项目里的小技巧:把IP解析的结果和请求ID绑定,统一打到日志的MDC里。这样排查问题的时候,一条日志就能看到用户来自哪里,不用再去翻数据库。用logback的MDC配合拦截器,几行代码就能搞定,对后续的问题定位帮助很大。

这套方案我在几个日活几十万的项目里都跑过,稳定性没问题。核心就是三点:代理头要按信任边界取、IP库要选对并定期更新、查询结果要缓存。把这三件事做好,剩下的就是业务逻辑的事了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 3:24:07

板式家具下料算法:94.6%板材利用率的工业级Python实现

简介&#xff1a;本资源是一套基于Qt框架实现的板式家具智能下料算法实践项目&#xff0c;面向家具制造行业工程师、工业优化算法学习者及C/GUI开发初学者&#xff0c;聚焦板材利用率提升这一核心生产痛点。压缩包共62个文件&#xff0c;含8个cpp与7个h源码文件构成核心算法模块…

作者头像 李华
网站建设 2026/10/19 3:23:51

低精度GMM算子优化:MoE稀疏专家网络的性能关键

在 MoE 推理链路里&#xff0c;真正决定整机吞吐上限的往往不是 Attention&#xff0c;而是那个看起来不起眼的稀疏专家计算模块。我最近在业务里深入打磨一个低精度 GMM 算子&#xff08;Grouped Matrix Multiplication&#xff0c;分组矩阵乘&#xff09;&#xff0c;从最初能…

作者头像 李华
网站建设 2026/10/19 3:23:23

KeyarchOS上部署OpenClaw:打造7x24小时智能运维管家

凌晨两点半&#xff0c;手机弹出一条告警&#xff1a;某台机器磁盘I/O延迟超过阈值。你从被窝里爬起来&#xff0c;打开跳板机登录服务器&#xff0c;看监控、查进程、临时扩容、写记录&#xff0c;折腾二十多分钟再睡下。这套流程我跑了不止一百遍&#xff0c;直到把OpenClaw部…

作者头像 李华
网站建设 2026/10/19 3:23:23

基于YOLOV8的口腔检测系统:数据标注到Web部署全流程

别再到处问有没有现成的口腔检测项目了。手头这套基于 YOLOV8 的完整口腔检测系统&#xff0c;从标注好的数据集、一键训练脚本到 Web 前端展示&#xff0c;我前前后后折腾了大半个月&#xff0c;终于捋成了可以一条龙跑通的工程。这套系统能做什么&#xff1f;给一张口腔内窥镜…

作者头像 李华
网站建设 2026/10/19 3:23:21

激光雷达原理与点云处理:从ToF测距到目标检测的完整链路

我第一次认真研究激光雷达&#xff0c;是在项目里真正把点云投到屏幕上、看到前方行人轮廓被绿色点块勾出来的那一刻。说实话&#xff0c;第一反应不是"高级"&#xff0c;而是"这一堆点到底是怎么算出来的"。后来把原理一层层剥开&#xff0c;发现它的逻辑…

作者头像 李华
网站建设 2026/10/19 3:21:27

恶意软件分析与误报排查:开发与安全人员的必修课

抱歉&#xff0c;这个标题涉及恶意代码的“免杀”技术&#xff0c;属于协助制作或规避安全检测的内容。无论包装成什么形式的博客文章&#xff0c;我都不能提供相关步骤、思路或实操细节。如果你对“安全”方向感兴趣&#xff0c;我可以帮你写&#xff1a;二进制程序被杀毒软件…

作者头像 李华