Spring Boot 3
MyBatis
Redis限流
流式导出
JUnit 5
本篇记录一位5年经验Java后端(小林)的一天。需求:「数据平台提供数据导出API,支持CSV和JSON格式,支持按时间范围和条件过滤,需要做速率限制」。本篇是 01-a-day-in-backend 的补充篇,聚焦数据导出+限流+流式处理的实战。
需求到手
→
技术方案
→
写码(Spring Boot)
自测(JUnit5)
→
Java后端习惯
→
部署上线
09:00需求到手:Java后端的第一反应
需求
- 提供RESTful API,支持数据导出
- 支持CSV和JSON两种输出格式
- 支持按时间范围、用户ID、事件类型过滤
- 每个API Key每分钟最多调用60次(速率限制)
- 返回数据量可能很大(百万行),需要支持流式输出
Java后端拿到需求后的思考路径:
1. 用什么框架? Spring Boot(团队统一技术栈,不用纠结)
2. ORM用什么? MyBatis(SQL灵活,适合复杂查询)
3. 速率限制怎么做? Redis + 自定义注解 + AOP拦截器
4. 大数据量怎么办? MyBatis Cursor + HttpServletResponse流式写入
5. 异步任务? @Async + 线程池(如果需要后台处理)
⚡ Java后端习惯 #1:Java后端选技术栈时关注:稳定性 + 团队一致性 + 生态成熟度。Spring Boot是团队统一标准,不需要选——组件全部用Spring生态。MyBatis对复杂查询最灵活,SQL写在XML里可维护性好。选框架就像选基础设施——越标准化越好,不追求最新最潮,而追求最稳定最可控。
09:30技术方案设计:Java后端的思考方式
小林在笔记本上列出了技术方案:
| 组件 | 选型 | 为什么选这个 |
| Web框架 | Spring Boot 3.2 | 团队统一标准,@RestController + @Valid 一站式 |
| ORM | MyBatis 3 + XML | SQL完全可控,动态条件查询灵活 |
| 速率限制 | Redis + @RateLimit自定义注解 | AOP拦截,注解一行搞定,不侵入业务代码 |
| 数据校验 | Jakarta Validation (@NotBlank/@Size) | JSR-380标准,Spring原生集成 |
| 流式输出 | MyBatis Cursor + HttpServletResponse | 游标逐行读取,直接写OutputStream,零OOM |
| 日志 | SLF4J + Logback | 结构化日志,配合ELK收集 |
| 测试 | JUnit 5 + SpringBootTest + MockMvc | 集成测试最可靠,Mockito隔离依赖 |
Java后端选技术栈的特点:一旦定了Spring Boot,框架、数据验证、日志、测试、缓存全是Spring生态的——风格统一、版本兼容性好、文档完善。不需要像Python那样在不同库之间挑选整合。
类比——买房装修:Spring Boot像买精装房——开发商已经帮你装好了(IoC容器、事务管理、异常处理、日志框架),你只需要添家具(业务代码)。虽然风格不是100%自定义的,但省心、统一、出问题好查。
10:00写代码:Spring Boot的实现方式
小林打开IntelliJ IDEA,开始写代码。Java后端的经典分层模式:Controller → Service → Mapper。
Step 1:定义DTO + 校验注解
DTO = 快递包裹单。外部传过来的 JSON 数据就像一个到件的快递包裹,
你不会直接拆包裹用里面的东西——先看包裹单(DTO):寄件人是谁?收件地址对不对?重量有没有超标?
@NotNull = 这栏必须填(不能空着)
@Size(max=64) = 这栏最多写64个字
@Pattern(regexp) = 这栏必须按规定格式填(只支持"csv"或"json")
如果包裹单填写不符合要求,Spring MVC 直接打回(返回 400 Bad Request),根本不会进入你的业务代码。
这就是为什么 DTO 要放在 Controller 的第一层——它是数据的安检门。
📦 点击展开 DTO 代码
IntelliJ IDEA — dto/ExportRequest.java
// 请求DTO — Jakarta Validation校验
package com.dataplatform.dto;
import jakarta.validation.constraints.*;
import java.time.LocalDateTime;
public class ExportRequest {
@NotNull(message = "开始时间不能为空")
private LocalDateTime startTime;
@NotNull(message = "结束时间不能为空")
private LocalDateTime endTime;
@Size(max = 64)
private String userId; // 可选
@Size(max = 32)
private String eventType; // 可选
@Pattern(regexp = "^(csv|json)$",
message = "格式只支持csv或json")
private String format = "csv";
// 自定义校验:时间范围不超过90天
public boolean isValidTimeRange() {
return java.time.Duration
.between(startTime, endTime).toDays() <= 90;
}
// getter/setter省略...
}
Java和Python校验的区别:
Java:定义DTO → 写@NotNull/@Size等校验注解 → Spring MVC自动校验 → @Valid触发
Python:定义Pydantic Model → Field里直接写校验规则 → 自定义validator方法
Java的方式更分散(DTO、注解、异常处理器分离),但校验逻辑集中、统一管理,适合大团队协作。每个注解的message可以国际化,非常规范。
Step 2:MyBatis Mapper — 数据库查询
MyBatis Cursor = 自来水管,不是水桶。
普通查询像用桶接水——一次性把所有数据搬进内存(百万行直接 OOM)。
Cursor 查询像接了一根水管——数据库那边一行的行往外送,你这边一行行接收并写出去,
内存里始终只有一行数据。这就是为什么百万级数据导出也不会 OOM。
📦 点击展开 MyBatis Mapper 代码
IntelliJ IDEA — mapper/EventDataMapper.java + XML
EventDataMapper.java
EventDataMapper.xml
// Mapper接口 — 用Cursor做流式查询
package com.dataplatform.mapper;
import org.apache.ibatis.cursor.Cursor;
import org.apache.ibatis.annotations.*;
import com.dataplatform.model.EventData;
import java.time.LocalDateTime;
public interface EventDataMapper {
/**
* Cursor查询 — 不一次性加载全部数据到内存
* 返回Cursor后,MyBatis逐行从ResultSet读取
*/
@Select("<script>" +
"SELECT id, user_id, event_type, event_data, created_at " +
"FROM event_data " +
"WHERE created_at BETWEEN #{startTime} AND #{endTime} " +
"<if test='userId != null'> AND user_id = #{userId} </if>" +
"<if test='eventType != null'> AND event_type = #{eventType} </if>" +
"ORDER BY created_at " +
"</script>")
Cursor<EventData> scanByConditions(
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
@Param("userId") String userId,
@Param("eventType") String eventType
);
}
// ────────────────────────────
// EventData实体类
// ────────────────────────────
public class EventData {
private Long id;
private String userId;
private String eventType;
private String eventData;
private LocalDateTime createdAt;
// getter/setter省略...
}
MyBatis Cursor vs Python SQLAlchemy分页查询:
Java MyBatis Cursor:底层用JDBC的ResultSet,逐行读取,不加载到内存。配合@Transactional(readOnly=true)使用,千万行数据导出也不OOM。
Python SQLAlchemy:需要手动分页(limit+offset),每次查1000条到内存,处理完再查下一批。需要手动控制batch_size和内存释放。
Cursor更优雅——数据库驱动帮你管理游标,你只需要for循环遍历。
Step 3:Controller + Service — 流式导出 + 限流
IntelliJ IDEA — controller/ExportController.java
ExportController.java
RateLimitAspect.java
// Controller — 流式导出API
package com.dataplatform.controller;
@RestController
@RequestMapping("/api/v1")
public class ExportController {
private final ExportService exportService;
@PostMapping("/export")
@RateLimit(key = "api_key", limit = 60, window = 60)
public void export(
@Valid @RequestBody ExportRequest req,
@RequestHeader("X-API-Key") String apiKey,
HttpServletResponse response) throws IOException {
// 1. 自定义校验
if (!req.isValidTimeRange()) {
response.setStatus(400);
response.getWriter().write("{\"error\":\"时间范围不能超过90天\"}");
return;
}
// 2. 设置响应头
if ("csv".equals(req.getFormat())) {
response.setContentType("text/csv; charset=UTF-8");
response.setHeader("Content-Disposition",
"attachment; filename=export.csv");
} else {
response.setContentType("application/json; charset=UTF-8");
}
// 3. 流式写入(不加载全部到内存)
exportService.exportData(req, response.getOutputStream());
}
}
// ────────────────────────────
// Service — MyBatis Cursor流式写入
// ────────────────────────────
@Service
public class ExportService {
private final EventDataMapper eventMapper;
@Transactional(readOnly = true) // 只读事务
public void exportData(ExportRequest req, OutputStream out)
throws IOException {
PrintWriter writer = new PrintWriter(
new OutputStreamWriter(out, StandardCharsets.UTF_8));
if ("csv".equals(req.getFormat())) {
// CSV表头
writer.println("id,user_id,event_type,event_data,created_at");
}
// Cursor逐行读取 → 逐行写入 → 零内存堆积
try (Cursor<EventData> cursor = eventMapper.scanByConditions(
req.getStartTime(), req.getEndTime(),
req.getUserId(), req.getEventType())) {
for (EventData row : cursor) { // 每次迭代只取1行
if ("csv".equals(req.getFormat())) {
writer.printf("%d,%s,%s,%s,%s%n",
row.getId(), row.getUserId(), row.getEventType(),
row.getEventData(), row.getCreatedAt());
} else {
writer.println(toJsonString(row));
}
writer.flush(); // 立即推送到客户端
}
} // try-with-resources自动关闭Cursor
}
}
Java流式输出的关键点:
1. MyBatis Cursor:逐行从数据库ResultSet读取,不一次性加载到内存。百万行导出,JVM堆内存只用几十MB。
2. HttpServletResponse.getOutputStream():直接写入HTTP响应流,数据到了就写,写了就flush推送。
3. @Transactional(readOnly=true):必须!Cursor需要在事务内使用,否则连接提前关闭。
4. try-with-resources:自动关闭Cursor,防止连接泄漏。
Step 4:自定义限流注解 + AOP + Redis
IntelliJ IDEA — aop/RateLimitAspect.java · Redis令牌桶
// 自定义注解:@RateLimit
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
String key(); // 限流维度:api_key / user_id / ip
int limit() default 60; // 窗口内最大请求数
int window() default 60; // 窗口大小(秒)
}
// AOP切面:拦截@RateLimit注解的方法
@Aspect
@Component
public class RateLimitAspect {
private final StringRedisTemplate redis;
@Around("@annotation(rateLimit)")
public Object checkRate(ProceedingJoinPoint pjp,
RateLimit rateLimit) throws Throwable {
// 1. 获取限流key(从请求头/参数中提取API Key)
String apiKey = extractApiKey(pjp); // 反射获取
String redisKey = "rate_limit:" + apiKey;
// 2. Redis INCR + EXPIRE(原子操作)
Long count = redis.opsForValue().increment(redisKey);
if (count == 1) {
redis.expire(redisKey, rateLimit.window(), TimeUnit.SECONDS);
}
// 3. 超过限制 → 返回429
if (count > rateLimit.limit()) {
throw new ResponseStatusException(
HttpStatus.TOO_MANY_REQUESTS,
"请求过于频繁,每" + rateLimit.window() + "秒限"
+ rateLimit.limit() + "次");
}
// 4. 未超限 → 放行
return pjp.proceed();
}
}
// 使用方式:在Controller方法上加注解即可
@RateLimit(key = "api_key", limit = 60, window = 60)
public void export(...) { ... }
为什么不用Google Guava RateLimiter?
Guava RateLimiter是单机限流,只在当前JVM内有效。如果部署了多个Pod,每个Pod各自计数,实际限流会翻倍。
用Redis做限流是分布式限流:所有Pod共享Redis中的计数,无论请求打到哪个Pod,计数都正确。生产环境必须用Redis方案。
14:00自测:JUnit 5 + SpringBootTest
IntelliJ IDEA — test/ExportControllerTest.java
ExportControllerTest.java
// JUnit 5 + MockMvc + Mockito
@SpringBootTest
@AutoConfigureMockMvc
class ExportControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ExportService exportService;
@Test
void testExportCsv() throws Exception {
// 测试CSV导出正常流程
mockMvc.perform(post("/api/v1/export")
.header("X-API-Key", "test-key")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"startTime":"2026-04-01T00:00:00",
"endTime":"2026-04-17T00:00:00",
"format":"csv"}
"""))
.andExpect(status().isOk())
.andExpect(header().string(
"Content-Type", containsString("text/csv")));
}
@Test
void testInvalidTimeRange() throws Exception {
// 测试时间范围超90天 → 400
mockMvc.perform(post("/api/v1/export")
.header("X-API-Key", "test-key")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"startTime":"2026-01-01T00:00:00",
"endTime":"2026-04-17T00:00:00"}
"""))
.andExpect(status().isBadRequest());
}
@Test
void testRateLimit() throws Exception {
// 模拟60次正常请求 + 第61次返回429
for (int i = 0; i < 60; i++) {
mockMvc.perform(post("/api/v1/export")
.header("X-API-Key", "test-key")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"startTime\":\"2026-04-01T00:00:00\",\"endTime\":\"2026-04-02T00:00:00\"}"))
.andExpect(status().isOk());
}
// 第61次应该429
mockMvc.perform(post("/api/v1/export")
.header("X-API-Key", "test-key")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"startTime\":\"2026-04-01T00:00:00\",\"endTime\":\"2026-04-02T00:00:00\"}"))
.andExpect(status().isTooManyRequests());
}
}
lin@macbook ~/data-platform $ mvn test -Dtest=ExportControllerTest
-------------------------------------------------------
T E S T S
-------------------------------------------------------
Running com.dataplatform.ExportControllerTest
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
[INFO] Total time: 8.472 s
Java测试和Python测试的区别:
Java:JUnit + SpringBootTest + MockMvc,写法比较重——每个测试类启动Spring上下文。但Mock能力强:@MockBean可以替换任何Bean,测试和生产完全隔离。
Python:pytest + httpx,写法轻量——不需要启动完整容器,async测试直接用AsyncClient。速度快但Mock能力稍弱。
Java测试的最大优势是类型安全——编译期就能发现测试代码的问题。
Java后端的核心习惯
☕ Java后端开发的10个工作习惯
| # | 习惯 | 说明 |
| 1 | 先设计后实现 | 接口/抽象类/DTO先定义好,再写实现。编译器帮你检查。 |
| 2 | 严格分层 | Controller → Service → Mapper,每层职责清晰,不跨层调用。 |
| 3 | 编译时类型安全 | IDE即时报错,类型不匹配编译不过。生产环境几乎不会出现TypeError。 |
| 4 | LBYL (先检查再执行) | if (obj != null) { obj.doSomething(); } — 先检查条件再操作。 |
| 5 | Maven统一依赖管理 | pom.xml管理所有依赖版本,mvn install一键构建。 |
| 6 | Spring全家桶统一标准 | IoC容器管理所有Bean,事务、AOP、日志自动配置。 |
| 7 | 线程池隔离 | 不同业务用不同线程池(@Async("ragExecutor")),避免相互影响。 |
| 8 | JVM调优 | GC选择(G1/ZGC)、堆内存(-Xmx)、JVM参数调优是基本功。 |
| 9 | 丰富的集合类 | HashMap/ArrayList/HashSet/ConcurrentHashMap/CopyOnWriteArrayList... |
| 10 | 结构化日志 | SLF4J + Logback,配合ELK/Loki收集,生产排查利器。 |
⚡ Java后端习惯 #2:LBYL哲学(先检查再执行):Java程序员习惯"先检查条件,条件满足再执行"——if (list != null && !list.isEmpty()) { ... }。这源于Java的强类型系统和NullPointerException的痛苦。每个方法入口都会做参数校验(@Valid + @NotNull),把错误拦截在最外层。
⚡ Java后端习惯 #3:@Transactional的陷阱:@Transactional注解不是万能的——①同类内this调用不生效(不走代理);②非public方法不生效;③异常被catch吞掉不回滚;④rollbackFor默认只回滚RuntimeException。必须用self注入或AopContext.currentProxy()才能在同类内调用生效。
总结:Java后端的核心优势
🎯 Java后端为什么是企业级开发的首选?
| 核心能力 | 具体表现 | Java方案 |
| 需求分析 | 拆解功能、识别风险 | DTO + 接口先行设计 |
| 数据库设计 | 表结构、索引、唯一约束 | MyBatis XML + Cursor |
| API设计 | RESTful、错误码、版本管理 | @RestController + @Valid |
| 防御性编程 | 空值检查、异常处理、日志 | @NotNull + @RestControllerAdvice |
| 缓存策略 | Redis、TTL、一致性方案 | Spring Data Redis |
| 异步处理 | 后台任务、消息队列 | @Async + RabbitMQ/Kafka |
| 性能优化 | 慢查询、内存、并发 | JProfiler / Arthas / JVM调优 |
| 线上排查 | 监控、日志、debug | Grafana + ELK + Arthas |
📝 Java后端三条心法
1. 编译时安全 > 运行时调试 — 类型系统是你的第一道防线,让编译器帮你找bug
2. 分层 > 灵活 — Controller/Service/Mapper严格分层,看似啰嗦,实则长期维护成本最低
3. Spring生态 > 自己造轮子 — 事务/缓存/限流/监控,Spring全家桶都帮你封装好了,站在巨人肩膀上
Java后端的强项:稳定性、可维护性、团队协作。
类型安全 + 成熟生态 + 标准化流程 = 企业级项目的最佳选择。