如何实现 IP 归属地查询?
🎯 面试题:如何根据 IP 地址查询归属地?
一、IP 地址基础
IPv4:4 字节,范围 0 ~ 4,294,967,295
例:192.168.1.100 → 数值化后 = 3232235876
IPv6:16 字节,范围 0 ~ 2^128-1
例:2001:0db8:85a3:0000:0000:8a2e:0370:7334
查询任务:
输入一个 IP 地址(字符串)
输出归属地信息:国家 / 省份 / 城市 / 运营商
二、IP 库的数据结构
IP 库文件格式
每条记录:
[start_ip, end_ip, country, province, city, isp]
示例:
3232235776, 3232236031, 中国, 广东省, 深圳市, 电信
3232236032, 3232236287, 中国, 广东省, 深圳市, 联通
数值化方法
/**
* IP 字符串 → 数值
* 192.168.1.100
* = 192×256³ + 168×256² + 1×256¹ + 100
* = 3232235876
*/
public static long ipToLong(String ip) {
if (ip == null || ip.isEmpty()) return 0;
// IPv6 暂不处理(复杂度差异很大)
if (ip.contains(":")) return 0; // IPv6 placeholder
String[] parts = ip.split("\\.");
if (parts.length != 4) return 0;
return (Long.parseLong(parts[0]) << 24)
| (Long.parseLong(parts[1]) << 16)
| (Long.parseLong(parts[2]) << 8)
| Long.parseLong(parts[3]);
}
/**
* 数值 → IP 字符串
*/
public static String longToIp(long ipNum) {
return ((ipNum >> 24) & 0xFF) + "." +
((ipNum >> 16) & 0xFF) + "." +
((ipNum >> 8) & 0xFF) + "." +
(ipNum & 0xFF);
}
三、二分查找定位归属地
核心思想
IP 库按 start_ip 升序排列
查找:给定 IP = 3232235900
↓
二分查找到第一个 end_ip >= 3232235900 的区间
↓
该区间即为 IP 所在范围
为什么二分而不是 HashMap?
IP 库有 40 万条记录
IP 值跨度很大(从几百万到几十亿)
HashMap 存储开销极大(key 是 8 字节数值)
二分查找 O(log N) = O(log 40万) ≈ 19 次
@Service
@Slf4j
public class IpGeoService {
// IP 段数组,按 startIp 升序排列
private IpSegment[] ipSegments;
// 按 startIp 构建的索引
private long[] startIpIndex;
@PostConstruct
public void loadDatabase() {
long start = System.currentTimeMillis();
List<IpSegment> segments = loadFromFile("/data/ipdb/ipv4_segment.dat");
this.ipSegments = segments.toArray(new IpSegment[0]);
// 构建索引数组,加速二分
this.startIpIndex = new long[ipSegments.length];
for (int i = 0; i < ipSegments.length; i++) {
startIpIndex[i] = ipSegments[i].startIp;
}
log.info("IP 库加载完成:{} 条记录,耗时 {}ms",
ipSegments.length, System.currentTimeMillis() - start);
}
/**
* 二分查找 IP 归属地
* 找到第一个 endIp >= targetIp 的区间
*/
public IpGeo query(String ip) {
long ipNum = ipToLong(ip);
if (ipNum <= 0) return IpGeo.UNKNOWN;
int idx = binarySearch(ipNum);
if (idx == -1) return IpGeo.UNKNOWN;
IpSegment seg = ipSegments[idx];
if (ipNum >= seg.startIp && ipNum <= seg.endIp) {
return new IpGeo(seg.country, seg.province, seg.city, seg.isp);
}
return IpGeo.UNKNOWN;
}
/**
* 二分查找:找第一个 endIp >= targetIp 的位置
*/
private int binarySearch(long targetIp) {
int lo = 0, hi = ipSegments.length - 1;
int result = -1;
while (lo <= hi) {
int mid = (lo + hi) >>> 1;
if (ipSegments[mid].endIp >= targetIp) {
result = mid; // 可能是答案
hi = mid - 1; // 继续向左找更小的
} else {
lo = mid + 1;
}
}
return result;
}
/**
* 预加载优化版:基于 startIp 做标准二分
* 找第一个 startIp > targetIp 的位置,取前一个
*/
private int binarySearchByStart(long targetIp) {
int lo = 0, hi = ipSegments.length - 1;
while (lo <= hi) {
int mid = (lo + hi) >>> 1;
if (startIpIndex[mid] <= targetIp) {
lo = mid + 1;
} else {
hi = mid - 1;
}
}
// lo 指向第一个 > targetIp 的位置
// targetIp 落在 lo-1 的区间内
int idx = lo - 1;
if (idx >= 0 && targetIp <= ipSegments[idx].endIp) {
return idx;
}
return -1;
}
/**
* 加载 IP 库文件(纯真/裸 IP 库格式)
*/
private List<IpSegment> loadFromFile(String path) {
List<IpSegment> list = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
String[] parts = line.split(",");
if (parts.length < 5) continue;
list.add(new IpSegment(
Long.parseLong(parts[0].trim()),
Long.parseLong(parts[1].trim()),
parts[2].trim(), parts[3].trim(),
parts[4].trim(),
parts.length > 5 ? parts[5].trim() : ""
));
}
} catch (IOException e) {
log.error("加载 IP 库失败: {}", path, e);
}
return list;
}
@Data
@AllArgsConstructor
static class IpSegment {
long startIp;
long endIp;
String country;
String province;
String city;
String isp;
}
@Data
@AllArgsConstructor
static class IpGeo {
String country, province, city, isp;
static IpGeo UNKNOWN = new IpGeo("未知", "未知", "未知", "");
}
}
四、缓存优化
多级缓存
查询频率分布(典型):
80% 请求命中:TOP 20% 的 IP(热 IP)
20% 请求命中:冷门 IP
L1: 本地 ConcurrentHashMap(TTL 5 分钟,10000 条)
L2: Redis(TTL 1 小时,按 IP 哈希分 key)
L3: 二分查找 IP 库文件
@Service
@Slf4j
public class IpGeoCachedService {
@Autowired
private IpGeoService ipGeoService;
@Autowired
private RedisTemplate<String, String> redisTemplate;
// L1 本地缓存:Guava Cache 或 ConcurrentHashMap + TTL
private final LoadingCache<String, IpGeo> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build(key -> ipGeoService.query(key)); // CacheLoader:miss 时自动查 L2/L3
// L2 Redis 缓存的 key 前缀
private static final String CACHE_KEY_PREFIX = "ip:geo:";
public IpGeo query(String ip) {
// 1. L1 本地缓存
IpGeo cached = localCache.getIfPresent(ip);
if (cached != null) return cached;
// 2. L2 Redis 缓存
String redisKey = CACHE_KEY_PREFIX + ip;
Map<Object, Object> redisCached = redisTemplate.opsForHash().entries(redisKey);
if (!redisCached.isEmpty()) {
IpGeo geo = new IpGeo(
(String) redisCached.get("country"),
(String) redisCached.get("province"),
(String) redisCached.get("city"),
(String) redisCached.get("isp")
);
localCache.put(ip, geo);
return geo;
}
// 3. L3 查 IP 库
IpGeo geo = ipGeoService.query(ip);
// 4. 回填 L2
if (!geo.equals(IpGeo.UNKNOWN)) {
Map<String, String> fields = Map.of(
"country", geo.getCountry() != null ? geo.getCountry() : "",
"province", geo.getProvince() != null ? geo.getProvince() : "",
"city", geo.getCity() != null ? geo.getCity() : "",
"isp", geo.getIsp() != null ? geo.getIsp() : ""
);
redisTemplate.opsForHash().putAll(redisKey, fields);
redisTemplate.expire(redisKey, 1, TimeUnit.HOURS);
}
localCache.put(ip, geo);
return geo;
}
}
五、批量查询优化
/**
* 批量查询:单 IP → 批量 IN 查询
* 场景:日志分析,一次处理 1000 个 IP
*/
public Map<String, IpGeo> batchQuery(List<String> ips) {
Map<String, IpGeo> result = new HashMap<>();
List<String> missIps = new ArrayList<>();
for (String ip : ips) {
IpGeo geo = query(ip); // 走缓存,毫秒级
result.put(ip, geo);
if (geo == IpGeo.UNKNOWN) missIps.add(ip);
}
log.debug("批量查询: total={}, hit={}, miss={}",
ips.size(), ips.size() - missIps.size(), missIps.size());
return result;
}
六、常见 IP 库对比
| IP 库 | 大小 | 精度 | 查询速度 | 特点 |
|---|---|---|---|---|
| ip2region | ~10MB | 城市级 | ~0.1ms | 国产开源,Java 支持好 |
| 纯真 IP 库 | ~5MB | 城市级 | ~0.5ms | 最流行,免费,中文 |
| MaxMind GeoIP2 | ~50MB | 精确街道 | ~1ms | 国际权威,精度最高 |
| qqwry.dat | ~5MB | 城市级 | ~0.5ms | 国内最常用的免费库 |
ip2region 使用示例:
@Service
public class IpGeoServiceImpl {
private Searcher searcher;
@PostConstruct
public void init() throws Exception {
// 启动时加载 ip2region 数据库文件
this.searcher = Searcher.newWithFileOnly(
"/data/ip2region/ip2region.xdb"
);
}
public IpGeo query(String ip) {
try {
long ipNum = ipToLong(ip);
// queryBtree: 二分搜索,最常用
// queryAll: 全量搜索,支持 IPv6
String region = searcher.search(ipNum);
// region 格式:国家|省份|城市|运营商|ISP
String[] parts = region.split("\\|");
return new IpGeo(
parts[0], parts[1], parts[2],
parts.length > 4 ? parts[4] : ""
);
} catch (Exception e) {
log.warn("IP 查询失败: ip={}, {}", ip, e.getMessage());
return IpGeo.UNKNOWN;
}
}
}
七、高频面试题
Q1: 为什么不用 HashMap 存储 IP 库?
IP 库有 40 万条记录,key 是 8 字节长整型,HashMap 存储开销很大(负载因子 0.75 时约 1.6 倍)。更重要的是,IP 是有序数值区间,天然适合二分查找,O(log N) = 19 次查找即可定位,远低于 HashMap 的空间成本。
Q2: IP 库如何实现增量更新?
方案一:定时拉取新版 IP 库文件,全量替换;方案二:打增量包(只下发变化区间),在内存中合并覆盖;方案三:按时间戳版本号,增量拉取后追加到现有数据尾部。生产推荐方案一,最简单可靠。
Q3: IPv4 和 IPv6 查询有什么区别?
IPv4 是 4 字节,数值范围小,可全量加载到内存用二分查找。IPv6 是 16 字节,范围极大,无法枚举,需要用 Radix Tree(前缀树)做最长前缀匹配,查询复杂度更高。
Q4: 如何保证查询的稳定性?
① IP 库文件加载到内存,查询不依赖外部服务;② 缓存穿透:未查到返回默认值,不抛异常;③ 多级缓存兜底:本地缓存 → Redis → 二分查找;④ 异常时降级到离线库或返回”未知”。
参考链接: