从"记住我"这一行代码,到数仓里百亿行日志的会话还原------
你缺的不是又一门认证课,而是一套白话说得通、代码跑得动、坑里踩得实的技术路线。
目录
1. 为什么需要它们
HTTP 天生是"鱼的记忆"------无状态。
浏览器说"我是张三",下一秒再请求,服务器一脸茫然:"你谁啊?"
大数据场景下,这直接导致两大痛点:
- 用户行为链路断裂:日志里每条点击像散落的珍珠,不知道哪几次请求是同一次访问。
- 安全鉴权成本高:数据接口每次都要重新验证身份,无法区分"刚登录"和"伪造请求"。
工程上的解决思路 :在无状态协议上"贴"一层状态。
于是出现三种最常见的方案:Cookie、Session、Token。
一句话直白版:
Cookie = 服务器给你贴的"临时工牌"。
Session = 服务器抽屉里存着你工牌对应的个人信息。
Token = 你手里一张自带防伪的"电子签证",服务器只看签证不存东西。
2. 核心概念拆解
2.1 Cookie
一句话直白 :浏览器替你保存的"便利贴",每次请求自动贴上。
生活例子:奶茶店集点卡,每次消费盖一个章,下次出示记录着你的点数。
java
// Java Servlet 里种一个 Cookie
Cookie userCookie = new Cookie("userId", "10086");
userCookie.setMaxAge(60 * 60 * 24); // 有效期秒数,-1 表示浏览器关闭即失效
userCookie.setPath("/");
response.addCookie(userCookie);
关键属性(开发常踩坑的地方):
| 属性 | 作用 | 注意事项 |
|---|---|---|
| Name/Value | 键值对 | 不能有分号、逗号、空格(需编码) |
| Domain | 允许发送到哪个域名 | 不设置则严格匹配当前域名 |
| Path | 允许发送的路径前缀 | /apis 下所有路径都会带 Cookie |
| Max-Age/Expires | 持久化时长 | 默认 -1 为会话 Cookie,关浏览器消失 |
| HttpOnly | 禁止 JS 访问 | 防 XSS 必须开 |
| Secure | 仅 HTTPS 传输 | 防中间人窃听 |
| SameSite | 防 CSRF | Strict/Lax/None,现代浏览器默认 Lax |
2.2 Session
一句话直白 :服务器内存或 Redis 里的一小块存储区,用唯一 ID 当索引。
生活例子:超市存包柜。你拿到一张小票(Session ID),服务员按小票编号找到你寄存的包。
典型工作流:
- 用户登录成功,服务器创建 Session 对象,存放
userId,role,loginTime。 - 服务器把 Session ID 通过
Set-Cookie: JSESSIONID=xxxx发给浏览器。 - 后续请求自动带上
JSESSIONID,服务器根据 ID 从内存/Redis 拿出对应用户信息。
java
// Servlet 里使用 Session
HttpSession session = request.getSession(); // 没 ID 则新建,有了就查找
session.setAttribute("user", userObject); // 往里存对象
User u = (User) session.getAttribute("user");
工程陷阱 ①: 分布式 Session 丢失
默认 Session 存在单机内存。Nginx 负载均衡后,A 节点生成的 Session,B 节点找不到 → 用户突然掉线。
避坑 :立即切换到 Redis 集中存储,Spring 项目引入spring-session-data-redis即可透明解决。
2.3 Token(典型代表 JWT)
一句话直白 :服务端签发的自带声明和签名的通关文牒,服务端不存你状态,只验签名真假。
生活例子:演唱会电子票,二维码里加密了"座位、姓名、有效期",门口一扫就行,不用查后台花名册。
JWT 结构:Header.Payload.Signature
- Header:签名算法(HS256 / RS256)
- Payload :声明(用户 id、过期时间等,不要放密码)
- Signature:用密钥对前两部分签名,防篡改
java
// 使用 jjwt 库生成 Token
String token = Jwts.builder()
.setSubject("10086") // 用户标识
.claim("role", "data_engineer")
.setExpiration(Date.from(Instant.now().plus(1, ChronoUnit.HOURS)))
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
验证 Token 时不用查库,只做数学运算 ------这是和 Session 最本质的区别。
在大数据场景中,常把脱敏的用户信息打在 Token 里,API 网关解析后直接放进请求头传给后端的 Spark/Flink 作业,省去反复查 Redis。
3. 一张表 + 一幅图讲清架构
3.1 三者速查对比表
| 维度 | Cookie | Session | Token (JWT) |
|---|---|---|---|
| 存储位置 | 浏览器 | 服务器(内存/Redis) | 客户端(浏览器/App) |
| 安全性 | 易被篡改/窃取 | 相对安全(Session ID 泄露仍有风险) | 签名防篡改,HTTPS 下安全 |
| 扩展性 | 无 | 差(需共享存储) | 好(无状态) |
| 移动端友好 | 差 | 一般 | 友好 |
| 额外开销 | 每次请求携带 | 每次查存储 | 加解密 CPU 开销 |
| 大数据友好度 | 低(字符串解析) | 中(需关联存储) | 高(自包含可解析) |
3.2 认证流"文字图示"
text
[登录] ------> 生成 Session ID | 签发 Token
| |
1. Set-Cookie Authorization: Bearer <token>
| |
[浏览器自动带Cookie] [代码手动加请求头]
| |
2. 服务器查 Session 3. 服务器验签名、解Payload
Session 是"中央集权",Token 是"去中心化"。大数据常走的路线是 Token + HMAC 签名,因为日志可以直接解析出用户信息,不用关联外部 DB。
4. 实战:从零构建一条用户行为日志的"会话还原"管道
我们设计一个大厂常遇的场景:
后端 Spring Boot 服务产生带有 Session/Cookie/Token 信息的用户行为日志,然后我们用 Spark SQL 解析日志,按真实 Session 进行会话窗口聚合。
4.1 日志生产端(Spring Boot Java)
核心:在拦截器中收集信息,MDC 放进日志。
java
// 拦截器:每次请求提取 userId 并放入 MDC
@Component
public class UserContextFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
try {
// 1. 尝试从 Session 取
HttpSession session = request.getSession(false);
if (session != null && session.getAttribute("userId") != null) {
MDC.put("userId", session.getAttribute("userId").toString());
}
// 2. 否则从 Token 取(JWT)
else {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
Claims claims = Jwts.parserBuilder()
.setSigningKey(secretKey).build()
.parseClaimsJws(token).getBody();
MDC.put("userId", claims.getSubject());
}
}
// 3. 始终记录 Session ID(如果有)
if (session != null) MDC.put("sessionId", session.getId());
chain.doFilter(request, response);
} finally {
MDC.clear(); // 防内存泄漏
}
}
}
Logback 配置输出 JSON 格式日志一行一条:
xml
<pattern>
{"ts":"%d{ISO8601}","userId":"%X{userId}","sessionId":"%X{sessionId}","url":"%m"}%n
</pattern>
实际日志行示例:
json
{"ts":"2026-05-13 10:01:02","userId":"10086","sessionId":"A1B2C3","url":"/api/data/pull"}
4.2 原始日志放入 HDFS
业务使用 Flume / Filebeat 采集,最终 HDFS 目录:
/user/logs/click/year=2026/month=05/day=13/
每个文件里是 JSON 行。
4.3 Spark SQL 会话还原
我们想统计每个用户每次 Session 内的行为路径长度 (即连续请求序列)。
Session 边界定义:同一 sessionId + 相邻请求间隔 > 30 分钟则视为新会话(虽然 sessionId 不变,但服务器 session 可能已过期,我们按业务语义切分)。
Spark Scala 代码(可直接运行在 Spark Shell):
scala
import org.apache.spark.sql.{SparkSession, functions => F}
import org.apache.spark.sql.expressions.Window
val spark = SparkSession.builder()
.appName("Sessionization")
.config("spark.sql.shuffle.partitions", "20") // 避免小文件爆炸
.getOrCreate()
// 1. 读取 JSON 日志
val rawDF = spark.read.json("/user/logs/click/")
.selectExpr("ts", "userId", "sessionId", "url")
.where("userId is not null and sessionId is not null")
// 2. 按 userId + sessionId 分组,并对时间排序计算间隔
val windowSpec = Window.partitionBy("userId", "sessionId").orderBy("ts")
val withLag = rawDF
.withColumn("prev_ts", F.lag("ts", 1).over(windowSpec))
.withColumn("gap_seconds",
F.col("ts").cast("long") - F.col("prev_ts").cast("long"))
// 3. 当 gap > 1800 秒 (30分钟) 或 gap 为 null(第一条) 标记为新会话起点
val withSessionFlag = withLag
.withColumn("is_new_session",
F.when(F.col("gap_seconds").isNull, F.lit(1))
.otherwise(F.when(F.col("gap_seconds") > 1800, F.lit(1)).otherwise(0)))
// 4. 累计求和生成全局唯一的 session_uid
val withSessionUID = withSessionFlag
.withColumn("session_uid",
F.concat(F.col("userId"), F.lit("_"), F.col("sessionId"), F.lit("_"),
F.sum("is_new_session").over(windowSpec.rowsBetween(Window.unboundedPreceding, Window.currentRow))))
// 5. 会话指标计算:每会话的请求数、路径列表
val sessionMetrics = withSessionUID
.groupBy("session_uid")
.agg(
F.count("*").as("event_cnt"),
F.collect_list("url").as("url_path")
)
sessionMetrics.show(10, false)
// 结果示例:
// +---------------------------+---------+----------------------------------+
// |session_uid |event_cnt|url_path |
// +---------------------------+---------+----------------------------------+
// |10086_A1B2C3_1 |5 |[/login, /home, /api/data, ...] |
// ...
工程陷阱 ②: Spark 窗口函数数据倾斜
如果某个 userId 有百万条数据,
partitionBy("userId", "sessionId")会导致单 Task 处理巨大数据量,严重倾斜 → OOM。
应对 :加盐打散超大用户,先用random()分桶并行度过窗口计算,再二次聚合。
4.4 Token 信息的批量解析(Spark UDF)
如果日志里直接记录了 token 字符串,你想在 Spark 中解析出 userId 而不依赖 Session 存储,可以写 UDF:
scala
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.security.Keys
import java.nio.charset.StandardCharsets
// 密钥通常放配置中心
val secretStr = "my-secret-key-for-spark-at-least-256bits!!!"
val key = Keys.hmacShaKeyFor(secretStr.getBytes(StandardCharsets.UTF_8))
val parseJwt = F.udf((token: String) => {
try {
val claims = Jwts.parserBuilder().setSigningKey(key).build()
.parseClaimsJws(token).getBody()
claims.getSubject // 返回 userId
} catch {
case _: Exception => null
}
})
// 使用
rawDF.withColumn("userId_from_token", parseJwt(F.col("token")))
这种方式避免对在线 Redis 的依赖,非常符合离线数仓自治原则。
5. 高级用法与避坑指南
5.1 分布式下 Session 的三条活路
| 方案 | 原理 | 适用 |
|---|---|---|
| 黏性负载均衡 | Nginx ip_hash 一直导到同一节点 | 集群规模小、停服升级难 |
| Session 复制 | Tomcat 集群互相广播 | 不可靠,网络风暴 |
| 集中缓存 | Redis/DB 存储 Session,所有节点访问同一份 | 生产首选,接入 Spring Session |
不用会怎样?用户登录后点"下一页"突然跳到登录页------你丢的不只是 session,还有用户的耐心。
5.2 JWT 安全四戒
- 绝不在 Payload 放密码、身份证号 ------ 它仅 Base64 编码,非加密。
- 必须设短过期时间(如 15 分钟),配合 Refresh Token 刷新。
- 算法不要设
none------ 曾经的安全漏洞:篡改 Header 为alg: none导致服务器直接相信。 - 密钥强度与保管 ------ 对称密钥至少 256 位,RS256 更安全(私钥签名,公钥验签)。
5.3 Cookie SameSite 与跨域埋点
大数据埋点通常用 img 请求上报,属于跨站请求,现代 Chrome 默认 SameSite=Lax 会阻止 Cookie 发送。
解决 :设置 SameSite=None; Secure,同时必须启用 HTTPS。
5.4 数仓里处理会话的"工程心法"
- 先聚合后用 UDF:优先使用 Spark SQL 内置窗口函数完成会话划分,复杂解析再用 UDF,避免性能衰减。
- 小文件合并 :会话表按日期分区写入时使用
.repartition(1)或.coalesce(1)容易造成单点瓶颈,应采取df.write.partitionBy("year","month").option("maxRecordsPerFile", 500000)。 - 解析 JSON 不爆炸 :
get_json_object比from_json慢且脆弱,优先定义 Schema 用from_json+StructType,既安全又类型严格。
6. 学习建议与进阶资源
- 立即动手:把实战代码改成你的本地 Docker 环境,HDFS 可用本地文件系统代替,感受"产生日志 → 窗口聚合"全流程。
- 溯源规范 :阅读 RFC 6265 (Cookie) 和 RFC 7519 (JWT),只读关键部分不超过一小时。
- 生产级增强 :
- Spring Session + Redis 官方 Demo
- jjwt 库的密钥轮换策略
- Spark Structured Streaming 中
flatMapGroupsWithState实现实时会话窗口
- 社区讨论:将你遇到的 Session 倾斜优化方案发到 Stack Overflow,或提交 CSDN 分享,碰撞出的经验最扎实。
7. 互动习题
题 1:在分布式环境下,为什么使用 Token 比 Session 更容易实现水平扩展?试结合存储位置与验证过程分析。
题 2 :某电商日志中,一个 userId 的一天行为有 500 万条请求,存为一个 sessionId(实际业务已按时间切分无数次)。你使用文中的 Spark 窗口函数方案,发现某 Task 耗时 2 小时,其他 Task 秒完成。请给出至少两种优化策略,并说明原理。
(解析统一见文末)
8. 小结
Cookie 是钥匙,Session 是锁在柜子里的档案,Token 是自带证明的自述书。
大数据最有价值的动作,不是在库前加上一层认证,而是让每一条日志都能自解释 ------知道你、记得你、还原你的每一步。
试着修改实战代码中的窗口间隔参数(比如 10 分钟),观察会话数量的变化,你会对"用户的定义"有更量化的感知。欢迎在评论区贴上你的实验结果,一起把坑踩成平地。
习题解析
题 1 解析 :
Token(JWT)的验证在服务端仅需本地计算签名,不依赖任何外部存储;Session 则必须访问集中存储(Redis/DB)来查询 Session 对象。水平扩展时,Token 方案每个节点都自持有密钥,加机器即可分担流量;Session 方案集中存储容易成为瓶颈,且 Redis 失效会导致全局不可用。因此 Token 天然解耦存储,扩展性更优。
题 2 解析(数据倾斜专题) :
根本原因是某个 userId + sessionId 组合的数据量极大,一个 Task 处理所有这 500 万行,导致计算倾斜。优化策略:
- 加盐打散 :在窗口计算前增加
salting字段(如随机 0~9),partitionBy("userId","sessionId","salt"),窗口计算后再额外聚合,最后按真实 key 二次 groupBy。 - 两阶段聚合 :第一阶段按
userId, sessionId预聚合,把连续行为压缩(例如利用 session 内事件计数替换原始明细),再用窗口纠正; - 增大并行度 与 广播小表 :仅对倾斜 key 生效时,可单独处理倾斜 key,将其数据使用 MapPartitions 手动排序分窗,其余 key 走常规窗口。同时配合
spark.sql.adaptive.skewedJoin.enabled=true对 join 类倾斜有效,但纯窗口需要自定义处理。