2026.07.20 — ARCHIVE — 3 MIN

一个 HTTP 请求的完整旅程,从浏览器到数据库

把浏览器发请求到数据库查数据这条链路串起来,涉及 Filter、Interceptor、Controller、Service、Mapper 各层职责。


一个 HTTP 请求的完整旅程,从浏览器到数据库

之前写项目的时候,经常会有这种感觉:

浏览器发了个请求,后台收到了,然后呢,然后就不知道了。

只知道 Controller 里写个注解,Service 里写点逻辑,Mapper 里写个 SQL,数据就查出来了。

但中间到底发生了什么,一直没想明白。

后来学了 SpringMVC、Filter、Interceptor、ThreadLocal 这些东西之后,终于能把这条链路串起来了。

今天就把一个 HTTP 请求从发出到返回的全过程梳理一遍。


起点:浏览器发出请求

假设用户在浏览器里点了「登录」按钮,填了用户名密码,点击提交。

这时候浏览器会发出一个 HTTP 请求:

POST /api/auth/login
Content-Type: application/json

{
  "username": "admin",
  "password": "123456"
}

这个请求会经过网络,最终到达我们的 Spring Boot 应用。


第一站:Filter(过滤器)

请求到达服务器之后,首先会经过 Filter。

Filter 是 JavaWeb 的三大组件之一(Servlet、Filter、Listener),工作在请求到达 Servlet 之前和响应返回客户端之前。

在我们项目里,Filter 主要干两件事:

第一件事:解析 Token

@WebFilter("/api/*")
public class TokenParseFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;
        String token = request.getHeader("token");
        
        if (token != null && !token.isEmpty()) {
            try {
                Claims claims = JwtUtils.parseToken(token);
                // 把用户信息存入 ThreadLocal
                UserContext.set(claims.get("userId", Integer.class),
                               claims.get("username", String.class));
            } catch (Exception e) {
                // Token 无效,不存
            }
        }
        chain.doFilter(req, res);
    }
}

第二件事:清理 ThreadLocal

请求结束后,一定要清理 ThreadLocal,不然可能会影响下一个请求。

@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
        throws IOException, ServletException {
    try {
        chain.doFilter(req, res);
    } finally {
        UserContext.remove();  // 清理 ThreadLocal
    }
}

第二站:Interceptor(拦截器)

Filter 处理完之后,请求会进入 SpringMVC 的拦截器。

Interceptor 比 Filter 更细粒度,可以针对特定路径进行拦截。

在我们项目里,Interceptor 主要做登录校验:

public class LoginCheckInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
        // 白名单:登录、登出接口不校验
        String url = req.getRequestURI();
        if (url.contains("/auth/login") || url.contains("/auth/logout")) {
            return true;
        }
        
        // 检查 ThreadLocal 中是否有用户信息
        if (UserContext.get() == null) {
            res.sendRedirect("/api/401");
            return false;
        }
        return true;
    }
}

登录接口不走拦截器,其他接口都要校验有没有登录。


第三站:Controller

拦截器放行之后,请求到达 Controller。

Controller 是 SpringMVC 的入口,负责接收请求、解析参数、调用 Service、返回响应。

@RestController
@RequestMapping("/emps")
public class EmpController {

    @Autowired
    private EmpService empService;

    @GetMapping
    public Result page(@RequestParam(defaultValue = "1") Integer page,
                       @RequestParam(defaultValue = "10") Integer pageSize,
                       Emp emp) {
        return Result.success(empService.page(page, pageSize, emp));
    }

    @PostMapping
    public Result save(@RequestBody Emp emp) {
        empService.save(emp);
        return Result.success();
    }

    @DeleteMapping("/{id}")
    public Result delete(@PathVariable Integer id) {
        empService.delete(id);
        return Result.success();
    }
}

@RestController 表示这是一个返回 JSON 的控制器。

@RequestMapping 映射请求路径。

@GetMapping@PostMapping@DeleteMapping 指定请求方式。

@RequestParam@RequestBody@PathVariable 分别获取请求参数、请求体、路径参数。


第四站:Service

Controller 调用 Service,Service 是业务逻辑的核心。

@Service
public class EmpServiceImpl implements EmpService {

    @Autowired
    private EmpMapper empMapper;

    @Override
    public PageResult page(Integer page, Integer pageSize, Emp emp) {
        PageHelper.startPage(page, pageSize);
        List<Emp> list = empMapper.selectByCondition(emp);
        Page<Emp> p = (Page<Emp>) list;
        return new PageResult(p.getTotal(), p.getResult());
    }

    @Override
    @Transactional
    public void save(Emp emp) {
        emp.setCreateTime(LocalDateTime.now());
        emp.setUpdateTime(LocalDateTime.now());
        empMapper.insert(emp);
    }

    @Override
    @Transactional
    public void delete(Integer id) {
        empMapper.deleteById(id);
    }
}

@Service 标记这是一个 Spring 管理的 Bean。

@Transactional 标记这个方法需要事务支持。

PageHelper 是分页插件,自动帮我们处理分页逻辑。


第五站:Mapper(数据访问层)

Service 调用 Mapper,Mapper 负责和数据库打交道。

@Mapper
public interface EmpMapper {

    List<Emp> selectByCondition(Emp emp);

    Emp selectById(Integer id);

    void insert(Emp emp);

    void update(Emp emp);

    void deleteById(Integer id);
}

对应 XML 映射文件:

<mapper namespace="com.myapp.mapper.EmpMapper">

    <select id="selectByCondition" resultType="Emp">
        SELECT * FROM emp
        <where>
            <if test="name != null and name != ''">
                AND name LIKE CONCAT('%', #{name}, '%')
            </if>
            <if test="gender != null">
                AND gender = #{gender}
            </if>
        </where>
        ORDER BY create_time DESC
    </select>

    <insert id="insert" parameterType="Emp" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO emp (name, gender, phone, job, entrydate, createTime, updateTime)
        VALUES (#{name}, #{gender}, #{phone}, #{job}, #{entrydate}, #{createTime}, #{updateTime})
    </insert>

</mapper>

MyBatis 会自动把 SQL 结果映射到 Emp 对象。


第六站:数据库

Mapper 执行 SQL,最终到达数据库。

MySQL 收到 SQL 之后,执行查询或更新,然后把结果返回给 MyBatis。

SELECT * FROM emp WHERE name LIKE '%张%' ORDER BY create_time DESC LIMIT 0, 10

这个 SQL 会遍历 emp 表,找出所有名字包含「张」的员工,按创建时间倒序排列,取前 10 条。


返回:从数据库到浏览器

数据从数据库返回之后,会沿着原路返回:

MySQL → MyBatis → Service → Controller → Interceptor → Filter → 浏览器

Controller 把结果包装成统一的响应格式:

{
  "code": 1,
  "msg": "success",
  "data": {
    "rows": [...],
    "total": 100
  }
}

然后经过 Interceptor 和 Filter,最终返回给浏览器。

浏览器收到响应,更新页面显示。


ThreadLocal:请求内用户传递

在整个请求链路中,有一个概念很重要:ThreadLocal

在 Filter 里,我们把解析出来的用户信息存入 ThreadLocal:

UserContext.set(claims.get("userId", Integer.class),
               claims.get("username", String.class));

然后在 Service 里可以直接获取当前用户:

Integer userId = UserContext.getUserId();

ThreadLocal 的作用是:在当前线程内共享数据,跨线程隔离。

好处是不需要层层传参,任何地方都能拿到当前登录用户。

但要注意两点:

  1. 请求结束后一定要清理:UserContext.remove()
  2. 不适合存大对象,只适合存用户 ID、用户名这种小数据

全局异常处理

请求过程中难免会出异常,怎么统一处理?

@RestControllerAdvice

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public Result handleBusinessException(BusinessException e) {
        log.error("业务异常:", e);
        return Result.error(e.getMessage());
    }

    @ExceptionHandler(SystemException.class)
    public Result handleSystemException(SystemException e) {
        log.error("系统异常:", e);
        return Result.error(e.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public Result handleException(Exception e) {
        log.error("未知异常:", e);
        return Result.error("系统繁忙,请稍后再试");
    }
}

这样不管哪个 Controller 抛出异常,都会被这个处理器捕获,返回统一的错误格式。


完整请求流程图

把整个过程串起来:

浏览器
  ↓ HTTP 请求
Filter(解析 Token,存入 ThreadLocal)
  ↓
Interceptor(校验登录状态)
  ↓
Controller(接收请求,解析参数)
  ↓
Service(业务逻辑,事务管理)
  ↓
Mapper(SQL 执行)
  ↓
MySQL(数据持久化)
  ↓
返回结果
  ↓
Controller(包装响应)
  ↓
Interceptor
  ↓
Filter(清理 ThreadLocal)
  ↓
浏览器

一句话总结

一个 HTTP 请求的完整旅程就是:

浏览器 → Filter → Interceptor → Controller → Service → Mapper → MySQL → 原路返回 → 浏览器

其中:

  • Filter 负责通用预处理(编码、Token 解析)
  • Interceptor 负责细粒度拦截(登录校验)
  • Controller 负责接收请求和返回响应
  • Service 负责业务逻辑
  • Mapper 负责数据访问
  • MySQL 负责数据存储

ThreadLocal 贯穿整个过程,在请求开始时存入用户信息,请求结束时清理。

全局异常处理器保证不管哪里出错,都能返回统一的错误格式。

理解了这条链路,再看 Spring Boot 项目,就不会觉得那么神秘了。

End of plate