从URL到MySQL:一条外卖请求的后端全链路拆解
接手过一个类似的外卖/电商类后端项目后再看苍穹外卖这套教学级系统我最大的感觉是它就是一份可以用作生产前练手的Web请求流转样本。很多人学完Spring Boot、MyBatis、Redis单个知识点都懂但被问到一个下单请求从浏览器发出去后端到底经过多少层、每层做了什么、为什么在这里做时就卡壳了。这篇文章干脆拿苍穹外卖后端做一个实体标本把一条Web请求从URL进入Nginx开始一路拆到MySQL返回结果再原路带回浏览器的全过程一笔一画讲清楚。不管你是刚学完SSM/Spring Boot想串链路的新手还是准备后端面试需要梳理架构逻辑的求职者亦或是刚接手项目不知道怎么下手的初级开发这条链路拆完你脑子里应该能自己画出一张完整的后端架构逻辑图。1. 一条外卖请求的全貌从URL输入到数据落库这件事1.1 先认清苍穹外卖后端的家底苍穹外卖不是一个大而全的微服务工程它更接近国内中小型互联网公司里最常见的单体分层架构Spring Boot作为应用骨架Spring MVC负责Web层路由MyBatis负责数据库访问MySQL存业务数据Redis做缓存Nginx挂在最前面做反向代理和静态资源服务。加认证用的是JWT加日志和公共字段填充用的是AOP再来一个Swagger/Knife4j生成接口文档。这些组件单拎出来任何一个都不稀奇但它们组合在一起之后一条请求要走的路径就非常有代表性了。你把这个项目的请求链路吃透再去看那些拆成微服务的订单系统、支付系统会发现核心思想完全一致只是多了服务间调用和消息队列而已。1.2 一次请求经过了哪几道门想象一下你去餐厅吃饭门口有迎宾Nginx大堂有服务员DispatcherServlet加拦截器点餐时服务员帮你找对应档口HandlerMapping后厨有配菜师傅Service层、切墩师傅Mapper层、食材仓库MySQL炒好的菜再按原路端回你桌上。这个类比虽然朴素但链路是准的。具体到苍穹外卖假设前端页面发起一个查询菜品列表的GET请求实际流转路径是这样的浏览器/小程序前端 - Nginx静态资源托管 /api接口反向代理 - Spring Boot 内嵌TomcatSocket监听、线程池分配 - DispatcherServletSpring MVC前端控制器 - 拦截器链JWT登录校验 / Token解析 - HandlerMapping定位到具体Controller方法 - HandlerAdapter执行参数解析与调用 - Controller层收参数、调Service、包统一响应 - Service层业务编排、事务控制、Redis缓存读写 - Mapper层MyBatis动态SQL、分页插件 - MySQL索引命中、数据落盘/读取 - 沿原路逐层返回最终渲染成JSON响应给前端这里要注意的是前后端分离项目里Spring MVC的ViewResolver视图解析环节基本被省掉了因为后端返回的是JSON而不是JSP/模板页面。所以苍穹外卖这套架构里请求的终点往往就是Controller方法里那个ResultT对象。1.3 为什么一定要这样分层很多人刚学后端时觉得分层是多此一举我直接在Controller里写jdbcTemplate查数据库不行吗短看确实行但你往长远看分层解决的是三个问题第一是变更隔离。假设数据库从MySQL换成PostgreSQL你只需要改Mapper层假设要加一套再来一单的校验规则你只需要改Service层假设接口返回格式从{code,msg,data}变成{success,result}你只需要动统一响应封装。每一层都有明确的边界改动不会像改一处坏十处的意大利面。第二是事务和业务逻辑的位置固定。事务放在Service层Controller只做参数接收和响应封装这样多个请求复用同一个业务方法时不会因为入口不同导致事务行为不一致。第三是可测试性。分层之后Service层可以脱离Web容器做单元测试Mapper层可以单独做数据访问测试排错时能快速定位到某一段链路。2. 入口分流Nginx与Web容器如何接住汹涌的请求2.1 Nginx在这里到底承担了什么苍穹外卖的前端是Vue项目后端是Spring Boot应用部署时通常会在两者之间加一个Nginx。很多人不太理解既然Spring Boot内置了Tomcat可以直接把端口暴露出去让前端调用为什么还要多此一举我的理解是Nginx在这套架构里至少干了三件事托管静态资源前端打包出来的dist目录包含html、css、js、图片这些文件如果交给Tomcat去读Tomcat的线程会被大量耗时极短的静态请求占据白白浪费Servlet容器的并发能力。Nginx处理静态文件的性能远高于Tomcat专业对口。反向代理与接口转发前端请求/api/xxx时Nginx用location /api/ { proxy_pass http://127.0.0.1:8080; }把请求转发给后端应用。这里有一个常规操作上的细节路径要注意proxy_pass末尾是否带/带不带会导致URL拼接结果完全不同。负载均衡虽然单体应用阶段只有一台后端但Nginx的upstream配置早就把将来横向扩容时加多个后端节点的路铺好了。把proxy_pass指向一个upstream组后续加一台服务器只需在组里加一行不需要动应用的任何代码。2.2 进入Tomcat后请求是怎么被接住的前端请求经过Nginx转发落到后端应用端口比如8080这时真正接活的是Spring Boot内嵌的Tomcat。Tomcat处理请求的线程模型可以简化为三个角色Acceptor线程专门负责接受新的TCP连接它收到连接后放入待处理队列不负责实际业务。工作线程池中的线程从队列取连接执行Servlet/Spring MVC的处理逻辑。如果工作线程全部处于繁忙状态新连接就在队列中排队等待。这个模型最关键的一个参数就是server.tomcat.max-threads默认值是200accept-count默认值是100。意思是同时最多有200个线程在处理请求超过200个后新的连接继续排队队列上限是100再多的请求就会被拒绝或超时。我在实际项目里见过一个很典型的场景某个慢SQL把单次请求的响应时间拖到了5秒而这5秒内Tomcat的所有线程都被这种慢请求占住了后来的正常请求全部排长队用户端表现就是接口转圈圈、动不动超时。这时候如果你不懂线程池模型很容易误判是网络问题或Nginx问题实际上瓶颈在后端线程资源被耗尽。2.3 线程池满的表现与排查思路遇到某个时间点大量请求卡住、部分报超时的问题建议按这个顺序排查先看Nginx的error.log和access.log确认请求是否已经转发到后端。如果Nginx层就大量5xx大概率是后端Tomcat连接被拒绝或长时间无响应。进入后端看线程栈执行jstack pid thread_dump.txt观察工作线程都卡在哪些代码上。如果一堆线程都卡在同一个JDBC连接获取点那就是连接池不够或慢SQL拖住了事务。再看HikariCP的监控指标确认活跃连接数是否长期接近maximum-pool-size比如默认的10。基于排查结论再决定优化方向是加索引解决慢SQL还是调大max-threads还是给数据库增加连接池上限。这里有个需要注意的原则无脑把max-threads加到2000解决不了问题尤其是当请求依赖数据库连接池时Tomcat线程再多数据库连接有限线程也只能阻塞等待反而加剧上下文切换开销。一般IO密集型的业务200到400线程是一个常见区间具体要压测后确定。3. 框架路由与请求预处理Spring MVC如何把URL变成方法调用3.1 DispatcherServlet的九条命请求进入Tomcat后会按照Servlet规范转发给Spring Boot中的DispatcherServlet它是Spring MVC的中央调度器。这个组件做的事情可以拆成四步通过HandlerMapping找到处理当前请求的Handler。Spring Boot里最常见的就是RequestMappingHandlerMapping它把GetMapping(/dish/list)这类注解布置成一张URL-方法的映射表。找到Handler之后先组装执行链处理器本身 拦截器集合。通过HandlerAdapter真正调用Controller方法。这里涉及参数的解析、转换、方法调用以及返回值的处理。前后端分离项目中通常没有视图渲染HandlerAdapter执行完Controller方法拿到ResponseBody返回的对象由HttpMessageConverter通常是Jackson库把对象序列化为JSON字符串写回响应。这一套流程最妙的地方在于你的Controller方法写出来只是孤零零的一个方法但Spring MVC给它套了参数解析、数据转换、拦截器、异常处理、消息转换这么多层外挂你只管写业务核心代码通用逻辑全部交给框架这就是框架设计上的典型模板方法责任链组合。3.2 Controller参数的凭空出现是怎么回事很多新手好奇Controller方法明明只是个普通方法为什么给它声明一个RequestBody DTO dto请求体里的JSON就能自动变成一个Java对象这是因为Spring MVC在调用方法前会使用HandlerMethodArgumentResolver接口的实现类来做参数解析。RequestBody走的是RequestResponseBodyMethodProcessor它读取请求体字符串使用Jackson的ObjectMapper执行反序列化RequestParam走的是RequestParamMethodArgumentResolver从request.getParameter()里取值然后做类型转换PathVariable则从URL模板变量里取值。这套机制里有几个高频的坑苍穹外卖项目里都能遇到日期格式问题前端传2025-01-19 12:30:00后端用LocalDateTime接收默认Jackson是无法直接识别的需要在配置里指定spring.jackson.date-format或者自定义一个全局Jackson定制器否则会反序列化失败。JSON字段命名问题前端用userId后端属性叫userId这没问题但一旦前端用了user_id这种带下划线的命名而后端没开spring.jackson.property-naming-strategy就会出现字段全部为空的情况。非法参数问题Integer类型参数如果收到非数字字符串直接抛MethodArgumentTypeMismatchException如果全局异常处理器没接住返回的就是一堆吓人的框架错误堆栈。3.3 统一响应与全局异常请求链上的兜底网苍穹外卖后端几乎所有的Controller方法返回的都是ResultT对象这个类里有三个字段code业务状态码、msg提示信息、data实际数据。统一响应体的好处有两个前端可以封装统一的拦截器处理业务错误后端可以统一处理成功/失败格式不让异常堆栈裸奔出去。和统一响应配合的还有全局异常处理器通常用RestControllerAdvice加ExceptionHandler实现。比如自定义一个BusinessException在Service层校验不通过时抛出异常处理器统一捕获并转换成Result.error(msg)返回给前端。我见过很多刚工作的开发完全依赖try-catch包住整个方法来处理异常结果每个接口都写重复的错误处理代码。换用全局异常之后Controller和Service只抛异常不处理代码干净非常多。但这也有个前提磁盘IO或网络IO这类必须原地释放资源的场景不能只用全局异常就不管了该用try-with-resources还是得用不能走极端。3.4 GET还是POST接口设计影响的不只是语义拆请求链路时不少人都忽略了HTTP方法本身也在影响架构行为。苍穹外卖项目里查询菜品、查看购物车这类读操作一般用GET下单、支付这类有副作用的写操作用POST。这不只是REST风格要求的还和缓存、幂等、请求器重放这些底层行为相关GET请求默认可以被浏览器缓存、被CDN缓存天然适合查询类请求POST不会自动缓存。GET参数是拼在URL里的容易被日志和抓包工具记录POST参数在Body里相对不容易泄露但出于安全考虑密码之类依然要做额外加密。POST配合后续要做的幂等处理能避免用户重复点击下单时产生重复订单。GET如果用来做扣款这类操作爬虫或预加载机制可能让你莫名损失。所以在设计接口时先想清楚这个请求是读还是写再去定义方法、定义缓存策略、定义是否需要考虑幂等链路往下走才不会出大乱子。4. 登录态校验这一层JWT拦截器、ThreadLocal与AOP审计4.1 为什么用拦截器而不是过滤器苍穹外卖里有用户端和管理端两套接口很多接口需要登录后才能访问。这个访问前先验证身份的逻辑放在Filter还是Interceptor里是个经典的架构选择题。Filter是Servlet规范层面的东西在Spring MVC还没有介入时就生效了它能拿到HttpServletRequest和HttpServletResponse但它拿不到HandlerMethod——也就是说你不知道这个请求最终会由哪个Controller方法处理无法根据方法上的注解做细粒度判断。HandlerInterceptor是Spring MVC提供的在DispatcherServlet找到Handler之后、执行Handler之前调用。它有一个很大的优势preHandle方法里可以通过HandlerMethod拿到方法上的注解比如自定义一个NoAuth注解标注哪些接口免登录拦截器里看到注解就直接放行。苍穹外卖里就用了这个思路部分登录接口、菜品查询接口可以匿名访问其他接口需要校验Token。如果用Filter你只能靠路径通配符来区分很不灵活用拦截器加注解就优雅得多。4.2 JWT Token校验的完整过程一个典型的需要登录的请求拦截器流程是这样的public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (handler instanceof HandlerMethod false) { return true; // 非Controller方法直接放行比如静态资源 } HandlerMethod handlerMethod (HandlerMethod) handler; if (handlerMethod.hasMethodAnnotation(NoAuth.class)) { return true; // 免登录接口 } String token request.getHeader(token); if (token null || token.isEmpty()) { response.setStatus(401); return false; } try { Claims claims JwtUtil.parseToken(token); Long empId Long.valueOf(claims.get(empId).toString()); BaseContext.setCurrentId(empId); // 放入ThreadLocal return true; } catch (Exception e) { response.setStatus(401); return false; } }这个流程出现次数高了之后你真的会意识到一个事实大多数后端接口的安全底线并不复杂就是一个Token解析一个用户上下文传递难的是如何把这段逻辑优雅地复用到每个请求上。拦截器就是Spring MVC对这种横切逻辑给出的标准答案。4.3 ThreadLocal存用户信息的正确用法为什么解析出用户ID后不直接用参数往下传而是塞进ThreadLocal因为Controller调Service时Service方法的入参原则上只该有业务需要的参数。如果为了拿到用户ID每个方法都加一个Long currentUserId参数代码会非常啰嗦而且入侵了业务逻辑的纯净度。ThreadLocal的设计目标就是同一个线程共享一份数据一次HTTP请求在Tomcat线程池中通常由同一个线程处理所以这个线程内部所有被调用的方法都能通过BaseContext.getCurrentId()拿到用户ID。这里有个非常关键的经验请求结束后必须调用BaseContext.removeCurrentId()清除ThreadLocal中的数据。否则由于Tomcat基于线程池复用线程当前线程下一次被分配给另一个请求时ThreadLocal里残留的上一个用户ID就会被下一个请求读到造成严重的数据串号问题。轻则订单创建到别人名下重则权限越权。我自己就踩过这个坑排查了一整天才发现是ThreadLocal没清理。建议把清理动作放在拦截器的afterCompletion方法里因为不管Controller是否抛出异常afterCompletion都一定会在请求完成后被回调比在业务代码里手动清理可靠得多。4.4 AOP操作日志每个写请求都有人记小本本苍穹外卖这类管理系统中后厨员工对菜品的修改、对订单状态的变更都属于敏感操作审计日志几乎是必须的。这个需求如果用拦截器做可以处理但更自然的框架级做法是AOP。通常做法是自定义一个Log注解标注在Controller方法上然后用Aspect定义切面Around(annotation(operateLog)) public Object doAround(ProceedingJoinPoint pjp, OperateLog operateLog) throws Throwable { long startTime System.currentTimeMillis(); Object result pjp.proceed(); long costTime System.currentTimeMillis() - startTime; // 从ThreadLocal取用户ID // 从pjp拿到方法全名、入参、出参 // 组装日志对象异步写入数据库或日志文件 return result; }实现要点是写入日志的动作最好不要同步阻塞主请求链路。如果每来一个下单请求都同步插入一条操作日志数据库压力会明显上升。常见的做法是先丢到内存队列或日志框架再异步批量落库。外层再配合一个定时任务兜底防止队列积压丢失。5. 业务层与数据层的接力事务、Redis缓存与MyBatis落库5.1 Service层的编排与事务边界拆到Service层请求才开始进入真正的业务逻辑。以苍穹外卖的下单功能为例一个insertOrder方法里面通常要同时干好几件事查询用户购物车数据。查询用户默认地址。计算订单总金额。扣减对应菜品的库存。插入订单主表记录。插入订单明细表记录。清空购物车。这7步如果中途第4步成功了、第5步失败了订单数据就会出现库存扣了但订单没建成的不一致状态。所以Service层要加Transactional把整个方法包成一个原子事务要么全部成功要么全部回滚。事务放在Service层的核心原因是一个事务应该就对应一个完整的业务单元。Controller可能只是Web层入口一个业务单元可能被不同Controller复用Mapper只是单表操作管不了跨表一致性。只有Service层最清楚哪些数据操作必须同时成功或同时失败。Spring的Transactional本质是通过AOP代理给方法外面套一层事务管理逻辑默认情况下只在RuntimeException或Error时回滚checked异常不会触发回滚。这个机制在实际开发中坑过不少人如果你在事务方法里调用了另一个类的公共方法并且主动try-catch吞掉了异常事务照样会提交数据一致性问题悄无声息地出现。所以事务方法内的异常处理要格外小心除非你能确认吞掉异常不会破坏数据一致性。5.2 Redis在业务链路里的位置与缓存策略苍穹外卖的项目里Redis主要用于缓存菜品分类、菜品列表和购物车数据。为什么不直接用MySQL扛所有查询因为外卖场景下用户打开App看一眼附近店铺和菜品这个动作读的频率远高于写的频率如果把所有读压力都打到MySQL数据库的CPU和IO会成为瓶颈一次大促流量就能让数据库拖垮整个应用。加一层Redis缓存把热点数据放在内存里读请求走内存写请求更新完数据库后再刷新缓存是这一类系统的典型缓解手段。实际项目中缓存读写用Spring Cache的Cacheable、CacheEvict注解比较多苍穹外卖里也大量用了这两个注解。这里有一个并发场景下的经典问题需要注意就是缓存穿透如果一个请求查的key在缓存中没有、在数据库也没有那么每次请求都会穿透缓存去打数据库。如果攻击者故意构造一堆不存在的ID去请求数据库可能被打挂。常用的简易方案是空值缓存查不到数据时也往Redis里写一个空对象设置较短的过期时间后续同类请求直接命中空缓存。更完备的方案是布隆过滤器属于进阶玩法单体项目里先学会空值缓存就够了。5.3 MyBatis查询如何走完最后一公里Service层编排完业务逻辑后最终要落到数据访问层。苍穹外卖使用的是MyBatis它的工作过程是Mapper接口定义方法XML或注解里写SQL框架通过动态代理生成实现类执行SQL时把结果集映射成实体对象。这里值得单独讲一下分页查询。菜品分页、订单分页是后端最常见不过的需求苍穹外卖中用了PageHelper分页插件。PageHelper的实现原理很有意思调用PageHelper.startPage(pageNum, pageSize)时它把分页参数通过ThreadLocal先存起来然后当MyBatis执行下一条SQL时拦截器拦截到Executor的查询方法从ThreadLocal里取出分页参数改写原SQL为SELECT ... LIMIT offset, pageSize再执行查询最后查总条数封装成PageInfo返回。理解了原理你就知道它的一个重要使用约束PageHelper.startPage之后必须紧跟第一条查询语句中间不能插入其他查询否则分页参数会作用到错误的SQL上。这个坑在苍穹外卖开发中很常见也是排查分页结果不对时的第一个检查方向。5.4 链接那一端的MySQL索引与连接池请求最后一站是MySQLMySQL的处理效率和两个因素强相关SQL是否走了合适的索引以及连接获取是否顺畅。我建议在本地开发时把MyBatis的SQL日志打开实际看一眼每个接口生成的SQL长什么样再用EXPLAIN检查执行计划type字段如果是ALL说明全表扫描就要考虑加索引了。比如订单查询按user_id和status过滤那么idx_user_status(user_id, status)就是一个典型的联合索引。连接池方面Spring Boot 2.x默认的HikariCP是一个高性能连接池默认maximum-pool-size为10。这里有个很有意思的点很多人觉得数据库连接池越大越好实际上对于一个CPU是4核8线程的小型应用20个数据库连接基本已经是充分利用资源的配置了。连接过多反而会让数据库线程上下文切换更严重。正确的做法是结合压测结果调整而不是盲目加大。6. 并发与多请求场景当批量请求器打出十个并行请求6.1 浏览器并发限制与批量请求器场景聊完单条请求的链路最后必须讨论一下并发——因为真实世界的用户不会按顺序一个一个点按钮。一个用户在浏览器中打开外卖App页面时可能会同时发出查分类查购物车查推荐菜品等多个Ajax请求HTTP/1.1协议下浏览器对同一域名一般只保持6个左右的并发连接超过的会排队。而如果使用web批量请求器这类工具做接口调试或压测一次性可以打出几十上百个并发请求这些请求会在同一瞬间涌向后端。这带来的第一个问题就是后端必须有能力处理同一时间多线程并发修改数据尤其是下单、支付这类写操作。很多新手写代码时默认请求是一个一个来的比如查库存时先SELECT再UPDATE但在并发场景下这种先查后改会出大问题。6.2 并发扣减库存的超卖风险举个例子某个菜品库存只剩1份两个用户同时下单。如果Service层这样写先查询SELECT stock FROM dish WHERE id1得到1然后判断stock 0再执行UPDATE dish SET stockstock-1 WHERE id1。两个线程可能都查到了库存为1都通过了判断都执行了扣减最后库存变成-1这就是超卖。正确的做法是让数据库层面保证原子性UPDATE dish SET stock stock - 1 WHERE id 1 AND stock 0;这条SQL将扣减和判断合并成一个原子操作数据库行锁保证同一时刻只有一个事务能成功执行返回受影响行数为1才表示扣减成功。必要时还可以配合乐观锁版本号或分布式锁处理更复杂的场景。这个案例很能说明问题链路拆解到最后很多并发问题是靠数据库层而不是应用层解决的因为应用层加锁要考虑跨线程、跨节点问题成本高得多。6.3 重复请求与幂等处理与并发相关的还有重复请求问题网络超时后前端自动重试、用户双击下单按钮、批量请求器同参数重放都可能导致同一个订单创建两次。解决思路分两层前端层面点击下单按钮后立即禁用按钮并显示loading阻止用户重复触发。后端层面利用唯一业务键做幂等。比如每次下单请求都携带一个客户端生成的订单号orderNo订单表对order_no建唯一索引后端起一个事务里先判断再插入重复请求插入时直接命中唯一索引报错捕获后返回订单已提交请勿重复操作。后端幂等处理不能只靠前端禁用按钮因为请求器或恶意脚本完全可以绕过前端控制。凡是涉及资金、订单、抽奖这类操作后端必须做幂等兜底。6.4 慢请求会如何拖垮整个应用最后一个常见场景是某个接口因为一个慢SQL或一次不合理的全表查询响应耗时飙升到5秒以上。如果这时有批量请求器持续对该接口发送请求会发生连锁反应Tomcat工作线程逐渐被慢请求占满新的正常请求进入队列排队。排队中的请求等待时间过长开始报连接超时或读超时。数据库连接池因为每个慢SQL都长时间占着连接不释放连接数被占满后续需要查库的其他接口也开始阻塞。应用整体的可用性雪崩所有接口看起来都挂了。这种情况下的止损手段包括对接口做限流降级Sentinel或简单的拦截器计数器、对热点数据加缓存、给慢SQL建立合适索引、设置合理的超时时间和线程池拒绝策略。但最根本的做法还是平时就上线全链路耗时监控。把一条请求拆分段的耗时埋点打出来Nginx耗时、Tomcat耗时、Service耗时、Redis耗时、MySQL耗时哪个环节超标一眼可见。写在后面的一点经验把这条链路完整画过一遍之后我最大的感触是后端架构并不玄乎它只是在回答四个问题请求从哪里进身份怎么验业务怎么做数据怎么存。苍穹外卖把这些答案都清晰地落到了具体组件和代码里。如果你刚接手一个新项目建议先别急着读业务代码而是沿着请求链路把Nginx配置、拦截器、Controller、Service、Mapper、Redis用法依次画出来这张图就是你走进这个系统的第一张地图。工具方面除了自己加日志埋点也可以试试Arthas之类的在线诊断工具直接在生产环境观察方法调用耗时接口调试建议在web批量请求器基础上再结合脚本做一轮并发测试观察并发场景下链路的表现。把这篇文章里的每一层验证一遍你对Web请求和后端架构的理解就会从好像会了变成真会了。