【苍穹外卖 | 项目日记】从零搭建:ThreadLocal与分页查询的实战解析

【苍穹外卖 | 项目日记】从零搭建:ThreadLocal与分页查询的实战解析 1. ThreadLocal线程安全的秘密武器第一次在苍穹外卖项目中看到ThreadLocal时我也是一头雾水。直到在新增员工接口中遇到用户信息传递问题才真正理解它的妙用。想象一下这样的场景当管理员在后台新增员工时系统需要自动记录当前操作者的ID。但在高并发环境下如果直接用全局变量存储用户ID多个线程同时操作时就会互相覆盖数据。这就是ThreadLocal大显身手的时候了。它就像给每个线程发了一个专属保险箱A线程存的东西B线程绝对拿不到。具体到我们的项目在用户登录后解析JWT令牌获取用户ID时可以这样使用// 定义ThreadLocal工具类 public class BaseContext { private static ThreadLocalLong threadLocal new ThreadLocal(); public static void setCurrentId(Long id) { threadLocal.set(id); } public static Long getCurrentId() { return threadLocal.get(); } public static void removeCurrentId() { threadLocal.remove(); } } // 在拦截器中设置用户ID String token request.getHeader(Authorization); Claims claims JwtUtil.parseJWT(token); Long empId claims.get(empId, Long.class); BaseContext.setCurrentId(empId); // 在Service层获取用户ID Long currentId BaseContext.getCurrentId();这里有个坑我踩过好几次ThreadLocal用完后一定要remove()特别是在使用线程池时线程会被重复利用如果不清理可能导致数据错乱甚至内存泄漏。曾经有个生产环境bug查了三天最后发现就是ThreadLocal没清理导致的。ThreadLocal的底层原理其实很有意思。每个Thread对象内部都有个ThreadLocalMap当调用set()方法时实际上是以当前ThreadLocal实例为key存入当前线程的map中。这种设计既保证了线程安全又避免了同步带来的性能损耗。2. PageHelper分页查询的瑞士军刀做后台管理系统分页查询是绕不开的功能。刚开始我都是手动写LIMIT语句直到发现PageHelper这个神器。在员工分页查询接口中原本需要十几行的分页逻辑用PageHelper三行就搞定了Override public PageResult pageQuery(EmployeePageQueryDTO dto) { // 魔法就在这里 PageHelper.startPage(dto.getPage(), dto.getPageSize()); PageEmployee page employeeMapper.pageQuery(dto); return new PageResult(page.getTotal(), page.getResult()); }PageHelper的巧妙之处在于它使用了MyBatis的拦截器机制。当调用startPage()时它会在当前线程设置分页参数后续执行的第一个查询语句会被自动改写。我翻看过源码发现它会在SQL执行前动态拼接LIMIT子句就像有个隐形的DBA在帮你优化查询。不过要注意几个细节PageHelper.startPage()必须紧挨着Mapper查询语句分页参数是线程绑定的所以异步场景需要特别注意复杂SQL可能需要手动优化count语句对于前端传参我们定义了清晰的DTOData public class EmployeePageQueryDTO { private String name; private Integer page; // 页码 private Integer pageSize; // 每条记录数 }3. 时间处理的两种姿势在前后端交互中时间格式总是个麻烦事。最早我每个日期字段都加JsonFormat注解后来发现这样太重复劳动。经过几次迭代最终采用了更优雅的方案。方案一注解方式public class Employee { JsonFormat(pattern yyyy-MM-dd HH:mm:ss) private LocalDateTime createTime; JsonFormat(pattern yyyy-MM-dd HH:mm:ss) private LocalDateTime updateTime; // 其他字段... }方案二全局消息转换器Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void extendMessageConverters(ListHttpMessageConverter? converters) { MappingJackson2HttpMessageConverter converter new MappingJackson2HttpMessageConverter(); converter.setObjectMapper(new JacksonObjectMapper()); converters.add(0, converter); } } // 自定义ObjectMapper public class JacksonObjectMapper extends ObjectMapper { public static final String DEFAULT_DATE_FORMAT yyyy-MM-dd; public static final String DEFAULT_DATE_TIME_FORMAT yyyy-MM-dd HH:mm:ss; public JacksonObjectMapper() { // 配置各种序列化规则... this.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); // 其他配置... } }全局方案虽然前期配置复杂些但后期维护成本低。特别是当需要统一调整日期格式时改一个地方就全部生效。不过要注意全局配置可能会影响某些特殊接口这时候可以用JsonFormat做局部覆盖。4. 接口设计中的Builder模式在修改员工状态接口中我第一次用Builder注解就被圈粉了。传统setter方式写起来啰嗦全参构造器又不够灵活Builder模式正好折中Builder Data public class Employee { private Long id; private Integer status; private LocalDateTime updateTime; private Long updateUser; } // 使用时代码非常清爽 Employee employee Employee.builder() .id(1L) .status(0) .updateTime(LocalDateTime.now()) .updateUser(BaseContext.getCurrentId()) .build();Builder模式特别适合参数多且可能变化的场景。在苍穹外卖项目中像订单、菜品这些复杂对象用Builder模式可以让代码更易读。Lombok的Builder注解帮我们自动生成构建器类省去了手写模板代码的麻烦。不过要注意几个细节需要配合Lombok使用默认生成的构建器不会处理父类字段可以和AllArgsConstructor、NoArgsConstructor组合使用5. 代理与负载均衡实战部署项目时Nginx的反向代理配置让我对网络架构有了更深理解。不同于正向代理帮客户端隐藏身份反向代理是帮服务器隐身。我们的配置大概长这样server { listen 80; server_name localhost; location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; } location / { root /usr/share/nginx/html; index index.html; } }这样配置后前端只需要访问统一的80端口Nginx会根据路径自动转发到后端服务。当需要扩展时只需在upstream中添加服务器节点就能实现负载均衡upstream backend { server 192.168.1.100:8080 weight5; server 192.168.1.101:8080 weight3; server 192.168.1.102:8080; }虽然开发阶段我们用单机测试但这样的架构设计为后续扩展留足了空间。曾经有次压测单机QPS到2000就撑不住了后来通过Nginx多节点轻松突破8000。6. 参数传递的三种方式在RESTful接口设计中我经常纠结该用哪种参数传递方式。经过多个接口的实战总结出以下经验路径参数用于标识资源GetMapping(/employees/{id}) public ResultEmployee getById(PathVariable Long id) { //... }查询参数用于过滤和分页GetMapping(/employees) public ResultPageResult list( RequestParam(required false) String name, RequestParam Integer page, RequestParam Integer pageSize) { //... }请求体参数用于复杂数据PostMapping(/employees) public Result save(RequestBody EmployeeDTO dto) { //... }实际开发中我建议资源ID用路径参数简单查询条件用查询参数复杂数据用请求体分页参数保持统一风格7. 消息转换的底层逻辑最后聊聊消息转换器这个幕后英雄。刚开始我对HttpMessageConverter的理解很模糊直到需要统一处理日期格式时才真正搞明白它的工作原理。Spring MVC处理请求时的大致流程前端发送JSON请求DispatcherServlet选择合适的ConverterConverter将JSON转为Java对象Controller处理业务逻辑返回时再次经过Converter转换我们自定义的JacksonObjectMapper实际上是在配置这个转换过程中的序列化规则。比如处理Java8时间类型public class JacksonObjectMapper extends ObjectMapper { public JacksonObjectMapper() { // 注册Java8时间模块 registerModule(new JavaTimeModule()); // 禁用时间戳格式 configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); // 其他配置... } }这种全局配置方式虽然前期投入大但后期维护起来特别省心。特别是在微服务架构中可以打包成starter供所有服务引用保证全系统日期格式统一。