“你都看到了谁?”——这句话放在后端场景里,其实是一道相当现实的题目:线上服务每一天都在接收大量请求,但如果你被问到“当前都有哪些人在访问系统”,能不能在几分钟内给出明确答案?这些人用的是浏览器还是脚本,来自哪些 IP,登录了哪些账号,访问了哪些接口,是否存在异常行为?
很多业务系统在开发阶段只关心功能能不能跑通,上线之后才发现“可观测性”非常欠缺:用户在线状态靠猜,访问来源靠翻日志,安全排查靠运气。本文就围绕“你都看到了谁”这个问题,拆解两条完整的落地方案:一条是用 Python 分析 Nginx 访问日志,从静态数据里还原访问者的痕迹;另一条是用 Spring Boot 实现一个在线用户列表,从动态维度实时掌握当前在线的人。同时会补充核心概念、环境准备、常见坑点和工程建议,适合后端开发者、运维工程师以及刚开始接触 Web 可观测性的新手。
1. 背景:这个问题为什么值得花时间研究
1.1 “在线用户”和“活跃用户”不是一回事
在动手之前,先把两个容易混淆的概念分开。
“在线用户”通常指当前仍然保持着会话状态、并且在最近一段时间内有请求活动的用户。比如一个用户登录了后台系统,五分钟内又刷新了一次页面,那他可以算作在线用户;如果他关掉浏览器已经两个小时,虽然会话记录还在,但已经不应该出现在“在线列表”里。
“活跃用户”则是一个更大的口径,一般指某个统计周期内有访问行为的用户。一天内有登录记录就算当日活跃,一周内有访问就算周活跃。在线状态强调的是“此刻”,活跃状态强调的是“某段时间”。很多团队在统计在线人数时数字虚高,往往就是把活跃口径混进了在线口径。
1.2 常见的三类访问来源
当我们试着回答“你都看到了谁”时,能观察到的信息其实来自三个层面。
第一层是网络入口层。只要请求到达服务器,就可以拿到来源 IP、端口,以及经过代理时携带的转发头信息。这一层能看到“哪个网络位置发起了请求”,但看不到“具体是哪个人”。
第二层是应用请求层。HTTP 请求里带有 User-Agent、Referer、Cookie、Authorization 等字段,可以判断客户端类型、来源页面、登录凭证。这一层能看到“请求用什么工具发出”“是否携带登录态”,但 User-Agent 和 Referer 都可以伪造。
第三层是业务身份层。用户登录之后,服务端会记录 userId、角色、权限、操作日志等。这一层才能把请求关联到具体账号,也是做在线用户列表、操作审计、安全风控的基础。
1.3 从“看到谁”到“做了什么”
只回答“谁在访问”其实还不够,更完整的可观测性还要回答“做了什么”。同样是看到一批 IP,如果只是常规浏览页面,那是正常流量;如果短时间内高频请求登录接口、遍历订单接口、批量下载文件,那就是需要重点关注的异常行为。
所以本文的两个实战并不冲突:日志分析适合回答“历史上有谁来过、谁频率异常”,在线用户列表适合回答“此刻都有谁在线、谁最近活跃”。两者结合,才能形成完整的访问者画像。
2. 核心概念:HTTP 请求中能“看到”谁
2.1 一次请求中可以被观察的字段
下面是一张常用的“可见信息”表,后端开发者最好能熟练判断哪些字段可用、哪些字段不可信。
| 信息 | 来源 | 说明 | 注意点 |
|---|---|---|---|
| 客户端 IP | remote_addr | 发起 TCP 连接的地址 | 经过代理后不一定准确 |
| X-Forwarded-For | 请求头 | 记录经过的代理链 | 可伪造,需在可信代理后方可信任 |
| User-Agent | 请求头 | 标识客户端类型 | 可伪造,只能作辅助判断 |
| Referer | 请求头 | 记录来源页面 URL | 可空,可伪造 |
| Cookie / Session | 请求头 | 登录态身份凭证 | 日志中不应输出完整 Cookie |
| Authorization | 请求头 | 接口认证凭证 | 日志中必须脱敏 |
| 请求方法 + 路径 | 请求行 | 用户行为痕迹 | 分析热点接口和异常路径 |
| 状态码 | 响应行 | 请求结果 | 大量 4xx/5xx 需要关注 |
在实际项目中,日志里最容易犯的错误就是把 Token、Cookie、密码明文打印出来。日志是给人排查用的,不是给攻击者送信息的,脱敏是一条底线。
2.2 在线用户状态的判定原理
在线状态最核心的模型是“心跳 + 过期”。
用户登录成功后,服务端记录一条在线记录,包含 userId、登录时间、最后活跃时间、IP 等信息。之后每次请求都看作一次心跳,刷新最后活跃时间。当某个用户超过设定时间没有任何请求,就判定为离线。
整体流程可以概括为三步:
- 登录成功后写入在线记录,设置最后活跃时间为当前时间。
- 请求进入拦截器,如果携带登录用户标识,则刷新该用户的最后活跃时间。
- 查询在线列表时,过滤掉最后活跃时间超过阈值的用户,即可得到实时在线列表。
这里最关键的是“阈值”。阈值太短,用户只是中途离开一会儿就被踢出在线列表;阈值太长,离线用户又长时间占着在线列表。通常 Web 管理后台会把阈值设置为 5 到 15 分钟,具体要根据业务场景调整。
2.3 日志分析的核心思路
访问日志分析本质上做三件事:读取、解析、统计。
Nginx 的 access.log 默认是combined格式,每行代表一次请求,包含 IP、时间、请求行、状态码、响应大小、Referer、User-Agent。只要写一个正则表达式把关键字段提取出来,再用Counter做频率统计,就能得到“哪些 IP 来得多、哪些 User-Agent 是爬虫、哪些接口被频繁访问”等信息。
日志分析是离线视角,适合做回顾性排查;在线用户列表是实时视角,适合做现网监控。两者一起用,才能更好回答“你都看到了谁”。
3. 环境准备与示例素材
3.1 版本说明
本文的示例环境如下,实际项目请根据自己的版本调整:
- 日志分析脚本:Python 3.8 及以上,仅使用标准库,不需要额外安装第三方包。
- 在线用户列表:Spring Boot 2.7.x,Maven 3.6+,JDK 1.8 或更高版本。
- 演示环境:Linux 或 Windows 均可,本文命令以 Bash 风格为主。
如果你使用的是 Spring Boot 3.x,需要注意javax.servlet包名变成了jakarta.servlet,核心思路不变,替换包名即可。
3.2 准备一份 access.log 示例
分析日志之前,我们需要有一个格式统一的日志文件。Nginx 默认的combined格式类似下面这样:
127.0.0.1 - - [10/Oct/2023:13:55:36 +0800] "GET /index.html HTTP/1.1" 200 2326 "https://example.com/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0 Safari/537.36" 192.168.1.100 - - [10/Oct/2023:13:56:02 +0800] "GET /api/user/list HTTP/1.1" 200 4512 "-" "python-requests/2.31.0" 192.168.1.100 - - [10/Oct/2023:13:56:03 +0800] "POST /api/login HTTP/1.1" 401 182 "-" "python-requests/2.31.0"从这份日志里,我们已经能看到三个层次的蛛丝马迹:IP 地址、请求路径、状态码、客户端工具。后文的正则就是按照这种格式编写的。
3.3 Spring Boot 演示项目结构
在线用户列表部分,我会搭建一个最小可运行项目,目录结构如下:
who-is-online ├── pom.xml └── src/main/java/com/example/whoisonline ├── WhoIsOnlineApplication.java ├── OnlineUser.java ├── OnlineUserManager.java ├── OnlineUserInterceptor.java ├── OnlineController.java ├── IpUtils.java └── WebConfig.java这个结构没有数据库、没有 Redis,核心是用内存 Map 保存在线用户,思路非常清晰,适合先跑通,再扩展。
4. 实战一:Python 脚本分析 Nginx 日志
4.1 先确认日志格式
不要拿到日志就直接套正则,先打开日志文件,看一两条真实记录,确认字段顺序和数量。本文的正则针对的是标准的combined格式,如果你在 Nginx 配置里添加了$request_time、$upstream_addr等自定义字段,就需要自行调整正则。
确认格式之后,可以把日志文件放在任意目录,脚本只负责读取和统计,不修改原文件。
4.2 编写日志解析脚本
下面是完整的analyze_access_log.py脚本。它的功能包括:逐行读取日志、提取 IP / 时间 / 请求 / 状态码 / UA、统计状态码分布、TOP IP、TOP UA、小时维度的请求趋势,以及标记高频访问者。
import re import sys import argparse from collections import Counter, defaultdict from datetime import datetime LOG_PATTERN = re.compile( r'^(?P<ip>\S+) \S+ \S+ \[(?P<time>[^\]]+)\] ' r'"(?P<request>[^"]*)" (?P<status>\d{3}) (?P<body_size>\S+) ' r'"(?P<referer>[^"]*)" "(?P<ua>[^"]*)"' ) def parse_line(line): match = LOG_PATTERN.match(line) if not match: return None return match.groupdict() def parse_time(time_str): return datetime.strptime(time_str, "%d/%b/%Y:%H:%M:%S %z") def extract_path(request): parts = request.split(" ") if len(parts) >= 2: return parts[1] return request def analyze_log(file_path, high_freq_threshold=60): ip_counter = Counter() ua_counter = Counter() status_counter = Counter() hour_counter = Counter() path_counter = Counter() invalid = 0 total = 0 with open(file_path, "r", encoding="utf-8", errors="ignore") as f: for line in f: record = parse_line(line) if not record: invalid += 1 continue total += 1 ip = record["ip"] ua = record["ua"] status = record["status"] path = extract_path(record["request"]) try: hour = parse_time(record["time"]).strftime("%Y-%m-%d %H") except ValueError: hour = "unknown" ip_counter[ip] += 1 ua_counter[ua] += 1 status_counter[status] += 1 hour_counter[hour] += 1 path_counter[path] += 1 print(f"总请求数: {total}") print(f"无法解析行数: {invalid}") print("\n=== 状态码分布 ===") for status, count in status_counter.most_common(): print(f"{status}: {count}") print("\n=== TOP 10 客户端 IP ===") for ip, count in ip_counter.most_common(10): print(f"{ip}: {count} 次") print("\n=== TOP 10 User-Agent ===") for ua, count in ua_counter.most_common(10): print(f"{ua}: {count} 次") print("\n=== TOP 10 请求路径 ===") for path, count in path_counter.most_common(10): print(f"{path}: {count} 次") print("\n=== 请求量 TOP 小时 ===") for hour, count in hour_counter.most_common(10): print(f"{hour}: {count} 次") print(f"\n=== 高频访问者(超过 {high_freq_threshold} 次/小时) ===") for ip, count in ip_counter.most_common(): if count >= high_freq_threshold: print(f"{ip}: {count} 次") def main(): parser = argparse.ArgumentParser(description="分析 Nginx access.log") parser.add_argument("logfile", help="日志文件路径") parser.add_argument("--threshold", type=int, default=60, help="高频请求阈值(次/小时),默认 60") args = parser.parse_args() try: analyze_log(args.logfile, args.threshold) except FileNotFoundError: print(f"文件不存在: {args.logfile}") sys.exit(1) if __name__ == "__main__": main()脚本里体现了几个工程细节:正则匹配失败的行不会中断程序,而是计入invalid;日志编码使用errors="ignore"避免个别乱码行导致崩溃;时间解析失败时归为unknown。这些处理在真实日志分析中非常实用。
4.3 运行与分析结果
在命令行执行:
python analyze_access_log.py /var/log/nginx/access.log --threshold 60如果日志文件在当前目录,可以简化为:
python analyze_access_log.py access.log运行后输出大致如下:
总请求数: 10234 无法解析行数: 0 === 状态码分布 === 200: 8231 301: 1023 404: 890 500: 90 === TOP 10 客户端 IP === 210.12.34.56: 1520 次 103.88.46.132: 980 次 ...看到这样的结果,第一反应不应该是直接封 IP,而是结合请求路径和 User-Agent 进一步判断:这些高频 IP 访问的是静态资源还是敏感接口?UA 是浏览器还是python-requests?如果两者都指向异常,再考虑安全策略。
4.4 扩展:识别高频访问者和爬虫
上面的脚本已经包含高频访问者识别功能。标准是“每小时超过 N 次”,N 由--threshold控制。
对于 User-Agent 中的爬虫特征,可以在脚本中增加一个简单的关键词集合:
SPIDER_KEYWORDS = ["bot", "spider", "curl", "python-requests", "scrapy"] def is_spider_ua(ua): ua_lower = ua.lower() return any(keyword in ua_lower for keyword in SPIDER_KEYWORDS)然后在解析循环里,对每条 UA 做一次判断,单独统计爬虫请求量。需要说明的是:正常搜索引擎爬虫也会访问站点,不一定要拒绝,但异常高频的脚本访问往往意味着接口被盗刷或数据被爬取,需要另做治理。
5. 实战二:Spring Boot 实现在线用户列表
5.1 需求与设计
在线用户列表的需求很直接:用户登录成功后出现在列表中;用户有请求时刷新最后活跃时间;超过 5 分钟没有请求则从列表消失;管理员可以查看当前在线用户。
为了不引入数据库和认证框架,本文在演示时用请求头X-User-Id模拟登录用户的身份。实际项目中,这个值应该来自登录后的 Token 解析结果,而不是让客户端随意传一个 userId。
5.2 项目结构与依赖
在 Spring Boot 项目中,只需要引入spring-boot-starter-web。pom.xml如下:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>who-is-online</artifactId> <version>1.0.0</version> <properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>5.3 核心代码实现
先创建启动类WhoIsOnlineApplication.java:
package com.example.whoisonline; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class WhoIsOnlineApplication { public static void main(String[] args) { SpringApplication.run(WhoIsOnlineApplication.class, args); } }再创建在线用户实体类OnlineUser.java。这里使用普通 JavaBean 而不是 Lombok,是为了避免读者因为 Lombok 版本问题导致编译失败:
package com.example.whoisonline; public class OnlineUser { private String userId; private String nickname; private String ip; private long loginTime; private long lastActiveTime; public String getUserId() { return userId; } public void setUserId(String userId) { this.userId = userId; } public String getNickname() { return nickname; } public void setNickname(String nickname) { this.nickname = nickname; } public String getIp() { return ip; } public void setIp(String ip) { this.ip = ip; } public long getLoginTime() { return loginTime; } public void setLoginTime(long loginTime) { this.loginTime = loginTime; } public long getLastActiveTime() { return lastActiveTime; } public void setLastActiveTime(long lastActiveTime) { this.lastActiveTime = lastActiveTime; } }核心管理类OnlineUserManager.java负责在线状态的写入、心跳续期、离线删除和在线列表查询:
package com.example.whoisonline; import org.springframework.stereotype.Component; import java.util.ArrayList; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @Component public class OnlineUserManager { private static final long EXPIRE_MILLIS = 5 * 60 * 1000L; private final Map<String, OnlineUser> onlineUsers = new ConcurrentHashMap<>(); public void online(String userId, String nickname, String ip) { OnlineUser user = new OnlineUser(); user.setUserId(userId); user.setNickname(nickname); user.setIp(ip); long now = System.currentTimeMillis(); user.setLoginTime(now); user.setLastActiveTime(now); onlineUsers.put(userId, user); } public void heartbeat(String userId, String ip) { OnlineUser user = onlineUsers.get(userId); if (user != null) { user.setLastActiveTime(System.currentTimeMillis()); if (ip != null && !ip.isEmpty()) { user.setIp(ip); } } } public void offline(String userId) { onlineUsers.remove(userId); } public List<OnlineUser> listOnlineUsers() { long now = System.currentTimeMillis(); List<OnlineUser> result = new ArrayList<>(); Iterator<Map.Entry<String, OnlineUser>> iterator = onlineUsers.entrySet().iterator(); while (iterator.hasNext()) { OnlineUser user = iterator.next().getValue(); if (now - user.getLastActiveTime() > EXPIRE_MILLIS) { iterator.remove(); } else { result.add(user); } } return result; } public boolean isOnline(String userId) { OnlineUser user = onlineUsers.get(userId); return user != null && System.currentTimeMillis() - user.getLastActiveTime() <= EXPIRE_MILLIS; } }这里有一个容易被忽略的细节:heartbeat方法不会为不存在的 userId 创建在线记录。也就是说,只有登录过的用户才能保持在线状态,单纯伪造一个X-User-Id请求头是没法让自己出现在在线列表里的。
IP 工具类IpUtils.java用于从请求中获取客户端地址:
package com.example.whoisonline; import org.springframework.util.StringUtils; import javax.servlet.http.HttpServletRequest; public class IpUtils { private IpUtils() { } public static String getClientIp(HttpServletRequest request) { String forwarded = request.getHeader("X-Forwarded-For"); if (StringUtils.hasText(forwarded)) { String first = forwarded.split(",")[0].trim(); if (!first.isEmpty()) { return first; } } String realIp = request.getHeader("X-Real-IP"); if (StringUtils.hasText(realIp)) { return realIp; } return request.getRemoteAddr(); } }拦截器OnlineUserInterceptor.java会在每个请求进入时读取用户标识并刷新活跃时间:
package com.example.whoisonline; import org.springframework.util.StringUtils; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; public class OnlineUserInterceptor implements HandlerInterceptor { private final OnlineUserManager onlineUserManager; public OnlineUserInterceptor(OnlineUserManager onlineUserManager) { this.onlineUserManager = onlineUserManager; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String userId = request.getHeader("X-User-Id"); if (StringUtils.hasText(userId)) { onlineUserManager.heartbeat(userId, IpUtils.getClientIp(request)); } return true; } }拦截器配置类WebConfig.java:
package com.example.whoisonline; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebConfig implements WebMvcConfigurer { private final OnlineUserManager onlineUserManager; public WebConfig(OnlineUserManager onlineUserManager) { this.onlineUserManager = onlineUserManager; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new OnlineUserInterceptor(onlineUserManager)) .addPathPatterns("/**") .excludePathPatterns("/api/login", "/error"); } }控制器OnlineController.java提供登录、登出、在线列表三个接口:
package com.example.whoisonline; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletRequest; import java.util.HashMap; import java.util.List; import java.util.Map; @RestController public class OnlineController { private final OnlineUserManager onlineUserManager; public OnlineController(OnlineUserManager onlineUserManager) { this.onlineUserManager = onlineUserManager; } @PostMapping("/api/login") public Map<String, Object> login(@RequestBody Map<String, String> body, HttpServletRequest request) { String userId = body.get("userId"); String nickname = body.get("nickname"); Map<String, Object> result = new HashMap<>(); if (userId == null || userId.trim().isEmpty()) { result.put("success", false); result.put("message", "userId 不能为空"); return result; } // 实际项目:这里应该先校验账号密码,再签发 Token,最后记录在线状态。 onlineUserManager.online(userId, nickname, IpUtils.getClientIp(request)); result.put("success", true); result.put("message", "登录成功,已记录在线状态"); return result; } @GetMapping("/api/online/list") public List<OnlineUser> onlineList() { // 生产环境必须给该接口增加管理员权限校验,不能匿名访问。 return onlineUserManager.listOnlineUsers(); } @PostMapping("/api/logout") public Map<String, Object> logout(@RequestBody Map<String, String> body) { String userId = body.get("userId"); Map<String, Object> result = new HashMap<>(); if (userId == null || userId.trim().isEmpty()) { result.put("success", false); result.put("message", "userId 不能为空"); return result; } onlineUserManager.offline(userId); result.put("success", true); result.put("message", "已下线"); return result; } }这段代码把登录模拟、在线查询、登出操作放在一起,完整可运行。生产环境需要把“从请求头取 userId”替换成“从 Token 解析 userId”,并给/api/online/list增加权限控制。
5.4 启动并验证
启动项目:
mvn spring-boot:run使用 curl 模拟用户登录:
curl -X POST http://localhost:8080/api/login \ -H "Content-Type: application/json" \ -d '{"userId":"10001","nickname":"张三"}'查询在线列表:
curl http://localhost:8080/api/online/list预期输出类似:
[ { "userId": "10001", "nickname": "张三", "ip": "127.0.0.1", "loginTime": 1696948536000, "lastActiveTime": 1696948536000 } ]再模拟一次心跳访问:
curl -H "X-User-Id: 10001" http://localhost:8080/api/online/list这次请求本身会触发拦截器,刷新lastActiveTime。如果你在 5 分钟内不再次请求,用户就会从在线列表中消失。
登出接口演示:
curl -X POST http://localhost:8080/api/logout \ -H "Content-Type: application/json" \ -d '{"userId":"10001"}'5.5 生产环境扩展:Redis 版思路
上面的内存版适合单机演示和小型项目。一旦系统部署了多个实例,每个实例的内存 Map 是各自独立的,用户在实例 A 登录,请求被负载均衡到实例 B 时,在线状态就丢失了。生产环境建议使用 Redis 作为在线状态存储。
核心思路是用 Redis 的 TTL 天然实现过期:
- key 为
online:user:{userId}; - 登录时写入该 key,值为用户信息的 JSON 字符串,设置 5 分钟过期;
- 每次请求心跳时刷新过期时间;
- 查询在线用户时,通过扫描
online:user:*或额外维护一个用户索引集合来获取在线列表。
使用StringRedisTemplate的核心片段思路如下:
stringRedisTemplate.opsForValue().set( "online:user:" + userId, userJson, Duration.ofMinutes(5) ); // 心跳续期 stringRedisTemplate.expire("online:user:" + userId, Duration.ofMinutes(5));这段代码展示了基本思路,实际使用时需要按项目的 Redis 客户端版本稍作调整。引入 Redis 后,内存 Map 的清理逻辑就不再需要了,TTL 会由 Redis 自动处理。
6. 常见问题与排查思路
6.1 在线人数统计为什么不准
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 在线人数长期虚高 | 过期时间太长,或者根本没有过期清理逻辑 | 设置合理的过期阈值,并在查询时过滤超时用户 |
| 在线人数波动剧烈 | 阈值太短,用户稍久没操作就被清除 | 根据业务场景调整 TTL,比如管理后台设为 15 分钟 |
| 用户退出后仍在在线列表 | 退出接口没有调用 offline 方法 | 在登出逻辑中主动删除在线记录 |
| 多个实例在线列表不一致 | 内存 Map 方案无法跨节点共享 | 改用 Redis 存储在线状态 |
6.2 为什么拿到的 IP 不是真实 IP
如果你的服务前面有 Nginx、SLB、CDN 等代理,request.getRemoteAddr()拿到的是代理服务器地址,而不是真实客户端 IP。
排查时先看X-Forwarded-For和X-Real-IP请求头有没有值。如果为空,说明代理层没有配置转发头;如果包含多个逗号分隔的地址,说明请求经过了多级代理,通常第一个是原始客户端地址,但没有绝对保证。
需要特别注意的是,X-Forwarded-For是客户端可伪造的。只有在 Nginx 层正确配置并覆盖该请求头,应用层拿到它才有意义。
6.3 日志脚本匹配不到记录
运行日志分析脚本时如果发现无法解析行数很大,大概率是日志格式和正则不匹配。
解决办法是先执行head -n 1 access.log看真实日志内容,然后对照正则里的字段顺序逐项确认。Nginx 默认的 combined 格式包含 IP、两个-占位符、时间、请求行、状态码、响应大小、Referer、UA。如果加了自定义字段,需要同步修改正则。
6.4 在线列表接口被当作“公开接口”滥用
在线用户列表接口如果没做权限控制,任何人都能查看系统当前有哪些用户在线,这是一个安全隐患。
最佳做法是:
- 将在线列表接口限制在管理后台或内网环境;
- 接口增加登录认证和管理员角色校验;
- 返回数据使用 VO 对象,只暴露必要字段,隐藏 IP 等敏感信息。
7. 最佳实践与工程建议
7.1 合规与隐私
在线用户信息、IP 地址、User-Agent 在特定场景下都可能属于个人信息。系统在采集这些数据前,需要遵循“合法、正当、必要”的收集原则,并在隐私政策中告知用户。
实际操作中有几条具体建议:
- 日志中不要打印完整 Token、Cookie、密码、手机号等敏感字段;
- 在线用户列表不向普通用户开放,只面向有权限的管理人员;
- 日志保留时间要有策略,比如保留 90 天或 180 天,过期清理;
- 分析日志时只处理自己有权限访问的服务器日志,不探测外部站点。
7.2 安全与性能
安全方面,在线用户列表、日志查询这类功能属于高敏感功能,要做单独的权限控制,不能混在普通业务接口里。
性能方面,需要注意以下几点:
- 不要在每个请求里都更新数据库,心跳更新应该使用 Redis TTL 或内存结构;
- 日志分析脚本要逐行读取,不要一次性把整个日志文件 load 进内存;
- 在线列表查询如果数据量大,要做分页;
- 在线状态阈值不要设成代码里的魔法数字,建议放入配置中心或
application.yml。
7.3 可维护性与拓展
在线用户功能看起来简单,但维护起来有不少细节。把过期时间做成配置项,可以方便在不同环境调整。登录时间、最后活跃时间、登录 IP 这些字段都应该记录,因为它们是安全排查的基础数据。
再往后拓展,可以把“在线用户”升级成“用户行为轨迹”:记录每个用户访问了哪些接口、停留了多久、操作顺序是什么。这时可以在请求入口埋点,再配合日志采集链路,逐步建立自己的可观测体系。
8. 总结与下一步学习方向
这篇文章用一个问句“你都看到了谁”串联了两条技术主线:一条是通过 Python 解析 Nginx 日志,从历史数据中还原访问者的来源和行为;另一条是通过 Spring Boot 实现在线用户列表,从实时数据中掌握当前在线用户。前者的关键词是“离线分析”,后者的关键词是“心跳过期”,两者并不冲突,而是互补关系。
如果要把这套能力做得更深入,下一步可以学习三类内容:第一是分布式会话与单点登录,解决在线状态在多实例下的一致性;第二是设备指纹与用户行为分析,从访问者识别过渡到用户风险识别;第三是可观测性体系搭建,比如通过 Prometheus 和 Grafana 对你的活跃用户指标做可视化监控。
最后,建议你手头有服务器日志的话,先把 Python 脚本跑一遍,看看自己系统里到底“都有谁”。动态在线列表也可以从一个最小的 Spring Boot 项目开始,理解了心跳和过期机制之后,再考虑引入 Redis。动手验证,永远比只看文章有效。如果这篇教程对你有帮助,可以先收藏备用,后面排查问题时再翻出来对照。