Tomcat 会话管理

Session Management

概述

会话管理是Web应用的核心功能,特别在集群环境中。本文详细介绍Tomcat的会话管理机制、持久化存储、集群会话复制和会话优化技术。

1. 会话管理基础

1.1 会话管理器类型

Tomcat会话管理器
├── StandardManager
│   ├── 内存存储
│   └── 单机应用
├── PersistentManager
│   ├── 文件存储
│   ├── 数据库存储
│   └── 持久化会话
├── DeltaManager
│   ├── 集群复制
│   └── 全节点同步
└── BackupManager
    ├── 备份复制
    └── 指定节点备份

1.2 基础会话配置

<!-- server.xml - 基础会话管理器 -->
<Context path="/myapp" docBase="myapp.war">

  <!-- 标准会话管理器 -->
  <Manager className="org.apache.catalina.session.StandardManager"
           maxActiveSessions="1000"
           sessionIdLength="16"
           randomClass="java.security.SecureRandom"
           algorithm="SHA1PRNG"
           entropy="/dev/urandom"
           maxInactiveInterval="1800"
           processExpiresFrequency="6"
           secureRandomClass="java.security.SecureRandom"
           secureRandomAlgorithm="SHA1PRNG" />
</Context>

2. 持久化会话管理

2.1 文件存储配置

<!-- 文件持久化会话管理器 -->
<Context path="/myapp" docBase="myapp.war">

  <Manager className="org.apache.catalina.session.PersistentManager"
           saveOnRestart="true"
           maxActiveSession="1000"
           minIdleSwap="10"
           maxIdleSwap="60"
           maxIdleBackup="30">

    <!-- 文件存储 -->
    <Store className="org.apache.catalina.session.FileStore"
           directory="/opt/tomcat/sessions"
           checkInterval="60" />
  </Manager>
</Context>

2.2 数据库存储配置

<!-- 数据库持久化会话管理器 -->
<Context path="/myapp" docBase="myapp.war">

  <!-- 数据源配置 -->
  <Resource name="jdbc/SessionDB" 
            auth="Container"
            type="javax.sql.DataSource"
            driverClassName="com.mysql.cj.jdbc.Driver"
            url="jdbc:mysql://localhost:3306/sessions"
            username="sessionuser"
            password="sessionpass"
            maxTotal="20"
            maxIdle="10" />

  <Manager className="org.apache.catalina.session.PersistentManager"
           saveOnRestart="true"
           maxActiveSession="2000"
           minIdleSwap="15"
           maxIdleSwap="120">

    <!-- 数据库存储 -->
    <Store className="org.apache.catalina.session.JDBCStore"
           dataSourceName="jdbc/SessionDB"
           sessionTable="tomcat_sessions"
           sessionAppCol="app_name"
           sessionIdCol="session_id"
           sessionDataCol="session_data"
           sessionValidCol="valid"
           sessionMaxInactiveCol="max_inactive"
           sessionLastAccessedCol="last_access"
           checkInterval="60" />
  </Manager>
</Context>

数据库表结构:

-- 会话存储表
CREATE TABLE tomcat_sessions (
    session_id VARCHAR(100) NOT NULL PRIMARY KEY,
    app_name VARCHAR(100) NOT NULL,
    session_data LONGBLOB,
    valid CHAR(1) NOT NULL,
    max_inactive INT NOT NULL,
    last_access BIGINT NOT NULL,
    INDEX idx_app_name (app_name),
    INDEX idx_last_access (last_access)
);

-- 清理过期会话存储过程
DELIMITER //
CREATE PROCEDURE CleanExpiredSessions()
BEGIN
    DELETE FROM tomcat_sessions 
    WHERE (UNIX_TIMESTAMP() * 1000 - last_access) > (max_inactive * 1000);
END //
DELIMITER ;

-- 设置定时清理
CREATE EVENT IF NOT EXISTS clean_sessions
ON SCHEDULE EVERY 1 HOUR
DO CALL CleanExpiredSessions();

3. Redis会话存储

3.1 Redis Manager配置

<!-- 自定义Redis会话管理器 -->
<Context path="/myapp" docBase="myapp.war">

  <Manager className="com.example.session.RedisSessionManager"
           host="redis.example.com"
           port="6379"
           password="redispass"
           database="0"
           maxTotal="50"
           maxIdle="20"
           timeout="5000"
           sessionTimeout="1800"
           keyPrefix="tomcat:session:" />
</Context>

3.2 Redis会话管理器实现

// RedisSessionManager.java
package com.example.session;

import org.apache.catalina.session.ManagerBase;
import org.apache.catalina.Session;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import redis.clients.jedis.Jedis;

import java.io.*;
import java.util.Base64;

public class RedisSessionManager extends ManagerBase {

    private String host = "localhost";
    private int port = 6379;
    private String password;
    private int database = 0;
    private String keyPrefix = "tomcat:session:";
    private JedisPool jedisPool;

    @Override
    public void startInternal() throws LifecycleException {
        super.startInternal();
        initializeRedisPool();
    }

    @Override
    public void stopInternal() throws LifecycleException {
        if (jedisPool != null) {
            jedisPool.close();
        }
        super.stopInternal();
    }

    private void initializeRedisPool() {
        JedisPoolConfig config = new JedisPoolConfig();
        config.setMaxTotal(50);
        config.setMaxIdle(20);
        config.setTestOnBorrow(true);

        if (password != null && !password.isEmpty()) {
            jedisPool = new JedisPool(config, host, port, 5000, password, database);
        } else {
            jedisPool = new JedisPool(config, host, port, 5000, null, database);
        }
    }

    @Override
    public Session findSession(String id) throws IOException {
        if (id == null) return null;

        try (Jedis jedis = jedisPool.getResource()) {
            String sessionData = jedis.get(keyPrefix + id);
            if (sessionData != null) {
                return deserializeSession(sessionData);
            }
        } catch (Exception e) {
            getLogger().error("Failed to load session from Redis: " + id, e);
        }
        return null;
    }

    @Override
    public void add(Session session) {
        try (Jedis jedis = jedisPool.getResource()) {
            String sessionData = serializeSession(session);
            String key = keyPrefix + session.getId();

            jedis.setex(key, getMaxInactiveInterval(), sessionData);
            sessions.put(session.getId(), session);
        } catch (Exception e) {
            getLogger().error("Failed to save session to Redis: " + session.getId(), e);
        }
    }

    @Override
    public void remove(Session session, boolean update) {
        try (Jedis jedis = jedisPool.getResource()) {
            jedis.del(keyPrefix + session.getId());
            sessions.remove(session.getId());
        } catch (Exception e) {
            getLogger().error("Failed to remove session from Redis: " + session.getId(), e);
        }
    }

    private String serializeSession(Session session) throws IOException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        oos.writeObject(session);
        oos.close();
        return Base64.getEncoder().encodeToString(baos.toByteArray());
    }

    private Session deserializeSession(String sessionData) throws IOException, ClassNotFoundException {
        byte[] data = Base64.getDecoder().decode(sessionData);
        ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data));
        Session session = (Session) ois.readObject();
        ois.close();
        return session;
    }

    // Getter和Setter方法
    public void setHost(String host) { this.host = host; }
    public void setPort(int port) { this.port = port; }
    public void setPassword(String password) { this.password = password; }
    public void setDatabase(int database) { this.database = database; }
    public void setKeyPrefix(String keyPrefix) { this.keyPrefix = keyPrefix; }
}

4. 集群会话复制

4.1 DeltaManager配置

<!-- 集群环境下的会话复制 -->
<Engine name="Catalina" defaultHost="localhost" jvmRoute="node1">

  <Cluster className="org.apache.catalina.ha.tcp.SimpleTcpCluster">

    <!-- Delta会话管理器 -->
    <Manager className="org.apache.catalina.ha.session.DeltaManager"
             expireSessionsOnShutdown="false"
             notifyListenersOnReplication="true"
             maxInactiveInterval="1800"
             stateTransferTimeout="60"
             sendAllSessions="true"
             sendAllSessionsSize="1000"
             sendAllSessionsWaitTime="2000" />

    <!-- 集群通信配置 -->
    <Channel className="org.apache.catalina.tribes.group.GroupChannel">
      <!-- Channel配置... -->
    </Channel>

    <!-- 会话复制阀门 -->
    <Valve className="org.apache.catalina.ha.tcp.ReplicationValve"
           filter=".*\.gif|.*\.js|.*\.jpeg|.*\.jpg|.*\.png|.*\.htm|.*\.html|.*\.css|.*\.txt" />

  </Cluster>
</Engine>

4.2 BackupManager配置

<!-- 备份会话管理器 - 只复制到指定节点 -->
<Manager className="org.apache.catalina.ha.session.BackupManager"
         mapSendOptions="6"
         notifyListenersOnReplication="true"
         rpcTimeout="15000" />

5. 会话安全配置

5.1 安全会话配置

<!-- web.xml - 会话安全配置 -->
<web-app>
    <session-config>
        <session-timeout>30</session-timeout>

        <!-- Cookie配置 -->
        <cookie-config>
            <name>JSESSIONID</name>
            <path>/</path>
            <comment>Session Cookie</comment>
            <http-only>true</http-only>
            <secure>true</secure>
            <max-age>1800</max-age>
            <same-site>Strict</same-site>
        </cookie-config>

        <!-- 会话跟踪模式 -->
        <tracking-mode>COOKIE</tracking-mode>
    </session-config>
</web-app>

5.2 会话安全过滤器

// SessionSecurityFilter.java
package com.example.session;

import javax.servlet.*;
import javax.servlet.http.*;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;

public class SessionSecurityFilter implements Filter {

    private static final ConcurrentHashMap<String, SessionInfo> sessionTracker = new ConcurrentHashMap<>();
    private int maxSessionsPerUser = 3;
    private int sessionTimeout = 1800; // 30分钟

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, 
                        FilterChain chain) throws IOException, ServletException {

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        HttpSession session = httpRequest.getSession(false);

        if (session != null) {
            // 检查会话固化攻击
            if (detectSessionFixation(httpRequest, session)) {
                session.invalidate();
                httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Session security violation");
                return;
            }

            // 检查并发会话
            String username = (String) session.getAttribute("username");
            if (username != null && !checkConcurrentSessions(username, session.getId())) {
                session.invalidate();
                httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Too many concurrent sessions");
                return;
            }

            // 更新会话信息
            updateSessionInfo(session, httpRequest);
        }

        chain.doFilter(request, response);
    }

    private boolean detectSessionFixation(HttpServletRequest request, HttpSession session) {
        String currentIP = request.getRemoteAddr();
        String currentUserAgent = request.getHeader("User-Agent");

        String sessionIP = (String) session.getAttribute("session.ip");
        String sessionUserAgent = (String) session.getAttribute("session.user.agent");

        if (sessionIP == null || sessionUserAgent == null) {
            session.setAttribute("session.ip", currentIP);
            session.setAttribute("session.user.agent", currentUserAgent);
            return false;
        }

        return !currentIP.equals(sessionIP) || !currentUserAgent.equals(sessionUserAgent);
    }

    private boolean checkConcurrentSessions(String username, String sessionId) {
        SessionInfo info = sessionTracker.get(username);

        if (info == null) {
            info = new SessionInfo();
            sessionTracker.put(username, info);
        }

        // 清理过期会话
        info.cleanExpiredSessions();

        // 检查会话数量
        if (info.getActiveSessionCount() >= maxSessionsPerUser && 
            !info.hasSession(sessionId)) {
            return false;
        }

        info.addSession(sessionId);
        return true;
    }

    private void updateSessionInfo(HttpSession session, HttpServletRequest request) {
        session.setAttribute("session.last.access", System.currentTimeMillis());
        session.setAttribute("session.request.count", 
            ((Integer) session.getAttribute("session.request.count")) + 1);
    }

    private static class SessionInfo {
        private final ConcurrentHashMap<String, Long> sessions = new ConcurrentHashMap<>();

        public void addSession(String sessionId) {
            sessions.put(sessionId, System.currentTimeMillis());
        }

        public boolean hasSession(String sessionId) {
            return sessions.containsKey(sessionId);
        }

        public int getActiveSessionCount() {
            return sessions.size();
        }

        public void cleanExpiredSessions() {
            long now = System.currentTimeMillis();
            sessions.entrySet().removeIf(entry -> 
                (now - entry.getValue()) > (1800 * 1000)); // 30分钟超时
        }
    }
}

6. 会话监控与管理

6.1 会话监控工具

// SessionMonitor.java
package com.example.session;

import javax.management.MBeanServer;
import javax.management.ObjectName;
import java.lang.management.ManagementFactory;
import java.util.Set;

public class SessionMonitor {

    private MBeanServer mbs = ManagementFactory.getPlatformMBeanServer();

    public void monitorSessions() throws Exception {
        Set<ObjectName> managers = mbs.queryNames(
            new ObjectName("Catalina:type=Manager,*"), null
        );

        System.out.println("=== 会话监控报告 ===");

        for (ObjectName manager : managers) {
            String context = manager.getKeyProperty("path");
            if (context.isEmpty()) context = "/";

            Integer activeSessions = (Integer) mbs.getAttribute(manager, "activeSessions");
            Integer maxActiveSessions = (Integer) mbs.getAttribute(manager, "maxActiveSessions");
            Long sessionCounter = (Long) mbs.getAttribute(manager, "sessionCounter");
            Long expiredSessions = (Long) mbs.getAttribute(manager, "expiredSessions");
            Integer sessionMaxAliveTime = (Integer) mbs.getAttribute(manager, "sessionMaxAliveTime");
            Integer sessionAverageAliveTime = (Integer) mbs.getAttribute(manager, "sessionAverageAliveTime");
            Integer rejectedSessions = (Integer) mbs.getAttribute(manager, "rejectedSessions");

            System.out.println("应用路径: " + context);
            System.out.println("  当前活跃会话: " + activeSessions);
            System.out.println("  最大活跃会话: " + maxActiveSessions);
            System.out.println("  会话总数: " + sessionCounter);
            System.out.println("  过期会话: " + expiredSessions);
            System.out.println("  最长存活时间: " + sessionMaxAliveTime + "秒");
            System.out.println("  平均存活时间: " + sessionAverageAliveTime + "秒");
            System.out.println("  拒绝会话数: " + rejectedSessions);

            // 计算会话使用率
            if (maxActiveSessions > 0) {
                double usage = (activeSessions * 100.0) / maxActiveSessions;
                System.out.println("  会话使用率: " + String.format("%.1f%%", usage));
            }
            System.out.println();
        }
    }

    public void cleanupExpiredSessions() throws Exception {
        Set<ObjectName> managers = mbs.queryNames(
            new ObjectName("Catalina:type=Manager,*"), null
        );

        for (ObjectName manager : managers) {
            try {
                mbs.invoke(manager, "processExpires", null, null);
                System.out.println("已清理过期会话: " + manager.getKeyProperty("path"));
            } catch (Exception e) {
                System.err.println("清理会话失败: " + e.getMessage());
            }
        }
    }
}

6.2 会话管理脚本

#!/bin/bash
# session-manager.sh

TOMCAT_PID=$(jps -l | grep Bootstrap | awk '{print $1}')

show_session_stats() {
    if [ -z "$TOMCAT_PID" ]; then
        echo "Tomcat进程未找到"
        return 1
    fi

    echo "=== 会话统计信息 ==="

    # 使用JMX获取会话信息
    java -cp "$CATALINA_HOME/lib/*" \
         -Dcom.sun.management.jmxremote \
         SessionMonitor 2>/dev/null || {
        echo "无法获取JMX数据,使用替代方法..."

        # 通过manager应用获取信息
        curl -s -u admin:admin123 \
             "http://localhost:8080/manager/text/sessions?path=/myapp" | \
        while read line; do
            echo "  $line"
        done
    }
}

clean_sessions() {
    echo "清理过期会话..."
    curl -s -u admin:admin123 \
         "http://localhost:8080/manager/text/expire?path=/myapp&idle=1800"
}

monitor_sessions() {
    echo "开始监控会话..."

    while true; do
        clear
        echo "会话监控 - $(date)"
        show_session_stats
        sleep 30
    done
}

backup_sessions() {
    echo "备份会话数据..."

    # 如果使用文件存储
    if [ -d "/opt/tomcat/sessions" ]; then
        tar -czf "/tmp/sessions_backup_$(date +%Y%m%d_%H%M%S).tar.gz" \
            /opt/tomcat/sessions/
        echo "会话文件已备份"
    fi

    # 如果使用数据库存储
    if command -v mysqldump &> /dev/null; then
        mysqldump -u sessionuser -psessionpass sessions tomcat_sessions > \
            "/tmp/sessions_$(date +%Y%m%d_%H%M%S).sql"
        echo "会话数据库已备份"
    fi
}

case "$1" in
    "stats") show_session_stats ;;
    "clean") clean_sessions ;;
    "monitor") monitor_sessions ;;
    "backup") backup_sessions ;;
    *) 
        echo "用法: $0 {stats|clean|monitor|backup}"
        echo "  stats   - 显示会话统计"
        echo "  clean   - 清理过期会话"
        echo "  monitor - 实时监控会话"
        echo "  backup  - 备份会话数据"
        ;;
esac

7. 会话优化最佳实践

7.1 会话优化配置

<!-- 优化的会话配置 -->
<Context path="/myapp" docBase="myapp.war">

  <Manager className="org.apache.catalina.session.StandardManager"
           maxActiveSessions="5000"
           sessionIdLength="32"
           randomClass="java.security.SecureRandom"
           maxInactiveInterval="1800"
           processExpiresFrequency="6"
           pathname=""  <!-- 禁用会话持久化到文件 -->
           randomFile="/dev/urandom" />

  <!-- 会话Cookie处理器 -->
  <CookieProcessor className="org.apache.tomcat.util.http.Rfc6265CookieProcessor"
                   sameSiteCookies="strict" />
</Context>

7.2 会话性能监控

// SessionPerformanceMonitor.java
package com.example.session;

public class SessionPerformanceMonitor {

    public void analyzeSessionPerformance() {
        System.out.println("=== 会话性能分析 ===");

        try {
            MBeanServer server = ManagementFactory.getPlatformMBeanServer();
            ObjectName managerName = new ObjectName("Catalina:type=Manager,path=/myapp");

            // 关键性能指标
            Integer activeSessions = (Integer) server.getAttribute(managerName, "activeSessions");
            Long sessionCounter = (Long) server.getAttribute(managerName, "sessionCounter");
            Long expiredSessions = (Long) server.getAttribute(managerName, "expiredSessions");
            Integer avgAliveTime = (Integer) server.getAttribute(managerName, "sessionAverageAliveTime");
            Integer maxAliveTime = (Integer) server.getAttribute(managerName, "sessionMaxAliveTime");

            // 计算性能指标
            double sessionTurnover = expiredSessions.doubleValue() / sessionCounter.doubleValue();
            double currentLoad = activeSessions.doubleValue() / 5000.0; // 假设最大5000会话

            System.out.println("当前活跃会话: " + activeSessions);
            System.out.println("会话周转率: " + String.format("%.2f%%", sessionTurnover * 100));
            System.out.println("当前负载: " + String.format("%.1f%%", currentLoad * 100));
            System.out.println("平均存活时间: " + avgAliveTime + "秒");
            System.out.println("最长存活时间: " + maxAliveTime + "秒");

            // 性能建议
            if (currentLoad > 0.8) {
                System.out.println("建议: 会话负载过高,考虑优化或扩容");
            }
            if (avgAliveTime < 300) {
                System.out.println("建议: 会话存活时间过短,检查应用逻辑");
            }
            if (sessionTurnover > 0.5) {
                System.out.println("建议: 会话周转率过高,检查会话配置");
            }

        } catch (Exception e) {
            System.err.println("性能分析失败: " + e.getMessage());
        }
    }
}

小结

通过本文学习,你应该掌握:

  1. Tomcat会话管理器的类型和配置
  2. 持久化会话存储的实现方法
  3. Redis等外部存储的集成技术
  4. 集群环境下的会话复制配置
  5. 会话安全防护措施
  6. 会话监控和性能优化技术
  7. 会话管理的最佳实践

下一篇文章将介绍Tomcat日志配置与管理。

powered by Gitbook© 2025 编外计划 | 最后修改: 2025-08-29 15:40:15

results matching ""

    No results matching ""