项目难点与亮点
概述
面试中"你的项目有哪些难点和亮点"是高频问题。本文按主题整理了可以在简历和面试中使用的技术方案,每个方案都采用"场景→方案→关键实现→效果"的结构。可以根据自己的项目情况套用。
如何挖掘项目亮点
亮点方向:
- 性能优化:缓存、数据库优化、异步处理
- 安全措施:数据加密、防注入、权限控制
- 代码质量:单元测试、模块化、设计模式
- 创新技术:新框架、新算法的应用
难点方向:
- 高并发处理
- 数据一致性(分布式场景)
- 线上问题排查
- 技术选型挑战
- 动态需求适应(配置中心、插件化、平台化)
一、接口安全与限流
API 密钥 + 滑动窗口限流
场景: API 暴露给外部/内部客户端调用,需要防止未授权访问和恶意刷接口。
方案:
- 认证:为每个用户生成唯一 API 密钥,请求时在 HTTP 头部附带密钥,服务端验证
- 限流:使用 Redis + 滑动窗口算法,记录每个请求的时间戳,超过阈值则拒绝
关键实现:
// API 认证过滤器
@Component
public class ApiKeyAuthenticationFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
String apiKey = ((HttpServletRequest) request).getHeader("X-API-KEY");
if (!isValidApiKey(apiKey)) {
((HttpServletResponse) response).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
chain.doFilter(request, response);
}
}// 滑动窗口限流(基于 Redis ZSET)
public boolean isAllowed(String userId, int maxRequests, long windowSizeInSeconds) {
String key = "rate_limit:" + userId;
long now = System.currentTimeMillis();
long windowStart = now - windowSizeInSeconds * 1000;
redisTemplate.opsForZSet().removeRangeByScore(key, 0, windowStart);
Long count = redisTemplate.opsForZSet().zCard(key);
if (count != null && count >= maxRequests) {
return false;
}
redisTemplate.opsForZSet().add(key, String.valueOf(now), now);
redisTemplate.expire(key, windowSizeInSeconds, TimeUnit.SECONDS);
return true;
}二、并发编排与性能优化
CompletableFuture 并发编排提升接口性能
场景: 某接口需要调用多个下游服务(如批量暂停扣款),串行执行耗时过长。
方案: 使用 CompletableFuture.allOf 并行执行所有下游调用,全部成功后推进后续流程。
关键实现:
CompletableFuture<Void> allFutures = CompletableFuture.allOf(
noticeDetails.stream()
.map(detail -> CompletableFuture.supplyAsync(() -> {
pause(detail);
return null;
}))
.toArray(CompletableFuture[]::new)
);
allFutures.whenComplete((v, e) -> {
if (e == null) {
// 所有暂停成功,执行后续还款操作
} else {
log.error("notice failed", e);
}
});效果: 50 笔订单暂停扣款从 10s 降至 1s。
热点数据预热 + 多级缓存 + 异步化
场景: 大促期间热门数据接口耗时长,数据库压力大。
方案:
- 缓存预热:大促前将热点数据加载到 Redis
- 多级缓存:本地缓存(Caffeine)+ Redis,减少远程调用
- 异步化:非核心操作(日志、统计)通过 MQ 异步处理
本地缓存实现:
@Bean
public Cache<String, Object> localCache() {
return Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build();
}多级缓存读取逻辑:
public Object getData(String key) {
// L1: 本地缓存
Object value = localCache.getIfPresent(key);
if (value != null) return value;
// L2: Redis
value = redisTemplate.opsForValue().get(key);
if (value != null) {
localCache.put(key, value);
return value;
}
// L3: 数据库
value = dbQuery(key);
redisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES);
localCache.put(key, value);
return value;
}效果: 整体性能提升 30% 以上。
三、定时任务与扫表
Quartz 定时任务 + 多线程分段扫表实现自动关单
场景: 订单到期需要自动关闭。原方案用 JDK DelayQueue,应用重启后队列丢失,导致超时订单未关闭仍被支付。
方案: 改用 Quartz 定时任务扫表实现。数据量大时引入多线程 + 分段(按 ID 区间划分)避免重复扫描。
关键实现:
// 定时任务
@Component
public class OrderAutoCloseQuartz extends QuartzJobBean {
@Override
protected void executeInternal(JobExecutionContext context) {
// 扫描到期未关闭的订单并执行关单
}
}
// 多线程分段扫表
Long minId = messageService.getMinInitId();
for (int i = 1; i <= threadPool.size(); i++) {
Long maxId = minId + segmentSize * i;
List<Message> messages = messageService.scanInitMessages(minId, maxId);
threadPool.submit(() -> process(messages));
minId = maxId + 1;
}XXL-JOB 分片实现分库分表后的扫表
场景: 分库分表后,扫表任务需要覆盖所有库表,单机处理不完。
方案: 基于 XXL-JOB 的分片功能,充分利用集群中所有实例进行任务处理。每个实例根据分片参数处理对应的库/表,内部再用生产者-消费者模式通过线程池快速消费。
四、幂等与防重
Token 校验避免订单重复提交
场景: 用户快速双击下单按钮,导致重复创建订单。
方案: 进入下单页时后端生成 Token 存入 Redis,提交订单时携带 Token,后端校验后立即删除。第二次提交时 Token 已不存在,拒绝请求。
关键实现:
// 生成 Token
public String generateToken(String userId) {
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set("order_token:" + userId, token, 10, TimeUnit.MINUTES);
return token;
}
// 校验 Token(原子操作)
public boolean validateToken(String userId, String token) {
String key = "order_token:" + userId;
// 使用 Lua 脚本保证 get + delete 的原子性
String script = "if redis.call('get',KEYS[1]) == ARGV[1] then "
+ "return redis.call('del',KEYS[1]) else return 0 end";
Long result = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(key), token);
return result != null && result == 1;
}"一锁二判三更新"实现支付幂等
场景: 支付回调可能重复通知,导致支付单被重复处理。
方案:
- 一锁:对支付单号加分布式锁
- 二判:查询支付单当前状态,如果已支付则直接返回
- 三更新:执行支付成功的业务逻辑
public void handlePayCallback(String payOrderId) {
RLock lock = redissonClient.getLock("pay_lock:" + payOrderId);
try {
lock.lock();
PayOrder payOrder = payOrderService.getById(payOrderId);
if (payOrder.getStatus() == PayStatus.PAID) {
return; // 已处理,幂等返回
}
// 执行支付成功逻辑
payOrderService.markAsPaid(payOrderId);
} finally {
lock.unlock();
}
}五、分布式锁
Redis 分布式锁解决短信验证码重复发放
场景: 用户快速多次点击"发送验证码",导致收到多条验证码短信。
方案: 在发送验证码前使用 Redisson 分布式锁,以手机号为 key 加锁。获取锁后再检查是否已发送过(双重校验)。
关键实现:
public void sendSmsCode(String phone) {
RLock lock = redissonClient.getLock("sms_lock:" + phone);
try {
if (lock.tryLock(0, 60, TimeUnit.SECONDS)) {
// 二次检查是否已发送
if (redisTemplate.hasKey("sms_code:" + phone)) {
return;
}
String code = generateCode();
smsService.send(phone, code);
redisTemplate.opsForValue().set("sms_code:" + phone, code, 5, TimeUnit.MINUTES);
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}六、ID 生成
雪花算法 + Redis 自增实现唯一订单号
场景: 分布式环境下需要生成全局唯一的订单号。
方案: 使用雪花算法生成唯一 ID,通过 Redis 自增 ID 分配 workerId(对 32 取模),避免手动配置。
关键实现:
@Component
public class WorkerIdHolder implements CommandLineRunner {
@Autowired
private RedissonClient redissonClient;
public static long WORKER_ID;
@Override
public void run(String... args) {
RAtomicLong atomicLong = redissonClient.getAtomicLong("workerId");
WORKER_ID = atomicLong.incrementAndGet() % 32;
}
}
// 生成订单号
public static String generateOrderId(BusinessCode code, String externalId) {
long id = IdUtil.getSnowflake(WorkerIdHolder.WORKER_ID).nextId();
return generate(code, externalId, id);
}关于 workerId 超过 32 的问题: workerId 是雪花算法中很小的一部分,即使重复也需要时间戳和 sequence 都相同才会导致 ID 冲突,概率极低。如果数据库报唯一性冲突,提示系统异常即可。
七、文件导入导出
EasyExcel + 线程池 + 批量插入实现百万级数据导入
场景: 数据迁移需要基于 Excel 导入百万级数据,避免内存溢出和性能过低。
方案:
- EasyExcel 逐行读取,不会一次性加载整个 Excel 到内存
- 线程池 多线程并行处理
- MyBatis 批量插入 减少数据库交互次数
EasyExcel + 线程池解决 POI 导出的内存溢出和超时
场景: 使用 POI 导出大文件时频繁 OOM,且接口超时。
方案:
- 改用 EasyExcel 流式写出,内存占用恒定
- 大数据量时分页查询 + 多线程写入
- 超大文件可以考虑异步导出 + 通知下载
八、缓存应用
Redis ZSET 实现秒级排行榜
场景: 需要实时更新的排行榜(如销量排行、积分排行)。
方案: 使用 Redis 的 ZSET(有序集合),score 为排序依据(如销量),member 为商品/用户 ID。
关键操作:
// 增加分数
redisTemplate.opsForZSet().incrementScore("rank:sales", productId, 1);
// 获取 Top N
Set<ZSetOperations.TypedTuple<String>> topN =
redisTemplate.opsForZSet().reverseRangeWithScores("rank:sales", 0, N - 1);
// 获取某个成员的排名
Long rank = redisTemplate.opsForZSet().reverseRank("rank:sales", productId);优点: ZSET 的 ZADD/ZINCRBY 是 O(logN),ZREVRANGE 是 O(logN + M),适合实时排行场景。
Redis Bitset 实现高效商品预约
场景: 商品预售时用户预约,需要高效记录和统计预约状态。
方案: 使用 Redis Bitmap,用户 ID 作为 offset,预约设置 bit 为 1。
// 预约
redisTemplate.opsForValue().setBit("reserve:" + productId, userId, true);
// 查询是否预约
Boolean reserved = redisTemplate.opsForValue().getBit("reserve:" + productId, userId);
// 统计预约人数
Long count = redisTemplate.execute((RedisCallback<Long>) conn ->
conn.bitCount(("reserve:" + productId).getBytes()));优点: 1 亿用户只占 12MB 内存,查询和设置都是 O(1)。
九、数据一致性
本地消息表实现分布式事务最终一致性
场景: 订单创建后需要通知库存服务扣减库存,要保证两边数据一致。
方案: 本地消息表 + 定时任务重试。订单创建时在同一个本地事务中写入消息表,定时任务扫描未发送的消息并发送 MQ,消费成功后更新消息状态。
流程:
- 本地事务:创建订单 + 写入消息表(状态=待发送)
- 定时任务:扫描"待发送"消息,发送到 MQ
- 消费端:处理消息,返回确认
- 生产端:收到确认后更新消息状态为"已完成"
状态机 + 乐观锁解决订单支付和关单的并发问题
场景: 订单支付回调和自动关单定时任务可能并发操作同一笔订单。
方案: 状态机限制合法的状态流转 + 数据库乐观锁(version 字段)保证并发安全。
UPDATE orders
SET status = 'PAID', version = version + 1
WHERE order_id = ? AND status = 'PLACED' AND version = ?如果受影响行数为 0,说明状态已被其他线程修改,拒绝本次操作。
十、代码质量
自定义注解 + AOP 切面减少冗余代码
场景: 多个接口都需要做相同的操作(日志记录、参数校验、幂等校验、缓存处理等),导致大量重复代码。
方案: 通过自定义注解标注需要特殊处理的方法,AOP 切面统一拦截处理。
典型应用场景:
- 统一日志记录(方法入参、出参、耗时)
- 统一幂等校验
- 统一缓存处理(查缓存 → 执行方法 → 更新缓存)
- 统一异常处理和告警
- 统一权限校验
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
String key();
long expireSeconds() default 60;
}
@Aspect
@Component
public class IdempotentAspect {
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint pjp, Idempotent idempotent) throws Throwable {
String key = resolveKey(idempotent.key(), pjp);
if (redisTemplate.hasKey(key)) {
throw new DuplicateRequestException("重复请求");
}
redisTemplate.opsForValue().set(key, "1", idempotent.expireSeconds(), TimeUnit.SECONDS);
return pjp.proceed();
}
}十一、线程上下文传递
基于 TTL 解决线程池中 ThreadLocal 无法共享的问题
场景: 使用线程池时,子线程无法获取父线程的 ThreadLocal 信息(如用户上下文、链路追踪 ID)。
方案: 使用阿里开源的 TransmittableThreadLocal(TTL),它可以在线程池场景下正确传递上下文。
原理: TTL 在提交任务到线程池时捕获当前线程的 ThreadLocal 值,在子线程执行时恢复,执行完毕后清理。
使用方式:
// 1. 用 TransmittableThreadLocal 替代 ThreadLocal
TransmittableThreadLocal<String> context = new TransmittableThreadLocal<>();
// 2. 用 TtlExecutors 包装线程池
ExecutorService executor = TtlExecutors.getTtlExecutorService(Executors.newFixedThreadPool(10));
// 主线程设置上下文
context.set("userId:12345");
// 子线程可以正确获取
executor.submit(() -> {
System.out.println(context.get()); // 输出: userId:12345
});十二、SpringEvent 同步转异步
基于 SpringEvent 解决定时任务扫表导致数据库连接池耗尽
场景: 定时任务扫表后对每条记录做后续处理,处理逻辑较重,导致长时间占用数据库连接,连接池不够用。
方案: 扫表后通过 Spring Event 发布事件,事件监听器异步处理具体逻辑,快速释放数据库连接。
// 发布事件
@Component
public class OrderScanJob {
@Autowired
private ApplicationEventPublisher publisher;
public void scan() {
List<Order> orders = orderMapper.scanExpired();
orders.forEach(order -> publisher.publishEvent(new OrderExpiredEvent(order)));
}
}
// 异步监听
@Component
public class OrderExpiredListener {
@Async
@EventListener
public void handle(OrderExpiredEvent event) {
// 处理过期订单(关单、退款等)
}
}效果: 扫表线程快速完成,不再长时间占用连接池;后续处理交由异步线程池执行。
附录:定时任务关单方案对比
在选择关单方案时,需要了解各方案的优缺点:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| JDK DelayQueue | 实现简单 | 基于内存,重启丢失 | 非核心业务,数据量小 |
| 定时任务扫表 | 可靠,不丢数据 | 有延迟,扫表有压力 | 数据量中等,对时效性要求不高 |
| RocketMQ 延迟消息 | 精确定时,高可靠 | 依赖 MQ 基础设施 | 数据量大,时效性要求高 |
| Redis 过期回调 | 实现简单 | 不可靠(可能丢失) | 辅助方案,不能作为唯一手段 |
| 时间轮 (HashedWheelTimer) | 高性能,低延迟 | 基于内存,重启丢失 | 大量短延迟任务 |
DelayQueue 的问题详解
原方案使用 JDK DelayQueue:
class Order implements Delayed {
private String orderId;
private long closeTime;
public Order(String orderId, long delayInMinutes) {
this.orderId = orderId;
this.closeTime = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(delayInMinutes);
}
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(closeTime - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed other) {
return Long.compare(getDelay(TimeUnit.MILLISECONDS), other.getDelay(TimeUnit.MILLISECONDS));
}
}
// 消费者线程
new Thread(() -> {
while (true) {
Order order = delayQueue.take(); // 阻塞等待到期
closeOrder(order);
}
}).start();致命问题: DelayQueue 基于 JVM 内存,应用崩溃或发布重启时队列中的订单全部丢失。
附录:Quartz 定时任务配置详解
@Configuration
public class OrderQuartzConfig {
@Bean
public JobDetail orderJobDetail() {
return JobBuilder.newJob(OrderAutoCloseQuartz.class)
.withIdentity("orderAutoCloseQuartz")
.storeDurably()
.build();
}
@Bean
public Trigger orderTrigger() {
// 每分钟执行一次
CronScheduleBuilder scheduleBuilder =
CronScheduleBuilder.cronSchedule("0 0/1 * * * ?");
return TriggerBuilder.newTrigger()
.forJob(orderJobDetail())
.withIdentity("orderAutoCloseQuartz")
.withSchedule(scheduleBuilder)
.build();
}
}附录:CompletableFuture 使用详解
常见编排模式
// 1. 多任务并行,等所有完成
CompletableFuture.allOf(futures).join();
// 2. 多任务并行,任一完成即返回
CompletableFuture.anyOf(futures).join();
// 3. 串行执行,上一步结果传给下一步
CompletableFuture.supplyAsync(() -> queryOrder(orderId))
.thenApply(order -> calculatePrice(order))
.thenAccept(price -> notifyUser(price));
// 4. 两个任务都完成后合并结果
CompletableFuture<String> orderFuture = CompletableFuture.supplyAsync(() -> getOrder());
CompletableFuture<String> userFuture = CompletableFuture.supplyAsync(() -> getUser());
orderFuture.thenCombine(userFuture, (order, user) -> buildResult(order, user));自定义线程池
默认使用 ForkJoinPool.commonPool(),建议自定义避免影响其他任务:
ExecutorService executor = new ThreadPoolExecutor(
10, 20, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
new ThreadPoolExecutor.CallerRunsPolicy()
);
CompletableFuture.supplyAsync(() -> doSomething(), executor);异常处理
CompletableFuture.supplyAsync(() -> riskyOperation())
.exceptionally(ex -> {
log.error("操作失败", ex);
return defaultValue;
});附录:分布式锁 Redisson 使用详解
基本用法
RLock lock = redissonClient.getLock("my_lock");
try {
// 尝试获取锁,等待 10s,锁持有时间 60s
if (lock.tryLock(10, 60, TimeUnit.SECONDS)) {
// 业务逻辑
}
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}Watchdog 机制
如果不指定 leaseTime,Redisson 会启动 Watchdog(看门狗),默认每 10 秒续期一次(总超时 30 秒),避免业务未完成锁就过期的问题。
与 setnx 的对比
| 特性 | setnx + expire | Redisson |
|---|---|---|
| 加锁解锁原子性 | 需要 Lua 脚本 | 自动保证 |
| 锁续期 | 不支持 | Watchdog 自动续期 |
| 可重入 | 不支持 | 支持 |
| 公平锁 | 不支持 | 支持 |
| RedLock | 需要自己实现 | 内置支持 |
附录:本地消息表完整实现
数据库表设计
CREATE TABLE local_message (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
message_id VARCHAR(64) UNIQUE NOT NULL,
topic VARCHAR(128) NOT NULL,
message_body TEXT NOT NULL,
status TINYINT DEFAULT 0 COMMENT '0-待发送 1-已发送 2-已确认 3-失败',
retry_count INT DEFAULT 0,
next_retry_time DATETIME,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);生产端逻辑
@Transactional
public void createOrder(OrderRequest request) {
// 1. 创建订单(本地事务)
Order order = orderService.create(request);
// 2. 写入本地消息表(同一事务)
LocalMessage message = new LocalMessage();
message.setMessageId(UUID.randomUUID().toString());
message.setTopic("order_created");
message.setMessageBody(JSON.toJSONString(order));
message.setStatus(0);
localMessageMapper.insert(message);
}
// 定时任务:扫描并发送
@Scheduled(fixedRate = 5000)
public void scanAndSend() {
List<LocalMessage> messages = localMessageMapper.selectPending();
for (LocalMessage msg : messages) {
try {
mqProducer.send(msg.getTopic(), msg.getMessageBody());
msg.setStatus(1); // 已发送
} catch (Exception e) {
msg.setRetryCount(msg.getRetryCount() + 1);
msg.setNextRetryTime(calculateNextRetry(msg.getRetryCount()));
}
localMessageMapper.update(msg);
}
}消费端逻辑
@RocketMQMessageListener(topic = "order_created")
public class OrderMessageConsumer implements RocketMQListener<String> {
@Override
public void onMessage(String message) {
Order order = JSON.parseObject(message, Order.class);
// 幂等校验
if (inventoryService.isProcessed(order.getOrderId())) {
return;
}
// 扣减库存
inventoryService.deduct(order);
}
}附录:乐观锁 + 状态机完整示例
数据库层
-- 订单表包含 version 字段
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
order_id VARCHAR(64) UNIQUE,
status VARCHAR(20) NOT NULL,
version INT DEFAULT 0,
-- other fields
);状态机定义
public enum OrderState {
PLACED, PAID, SHIPPED, CONFIRMED, CANCELED, REFUNDED
}
public enum OrderEvent {
PAY, SHIP, CONFIRM, CANCEL, REFUND
}
// 合法转换定义
private static final Map<OrderState, Map<OrderEvent, OrderState>> TRANSITIONS = Map.of(
OrderState.PLACED, Map.of(
OrderEvent.PAY, OrderState.PAID,
OrderEvent.CANCEL, OrderState.CANCELED
),
OrderState.PAID, Map.of(
OrderEvent.SHIP, OrderState.SHIPPED,
OrderEvent.REFUND, OrderState.REFUNDED
),
OrderState.SHIPPED, Map.of(
OrderEvent.CONFIRM, OrderState.CONFIRMED,
OrderEvent.REFUND, OrderState.REFUNDED
)
);Service 层
public void handleEvent(String orderId, OrderEvent event) {
Order order = orderMapper.selectByOrderId(orderId);
OrderState currentState = OrderState.valueOf(order.getStatus());
// 状态机校验
Map<OrderEvent, OrderState> allowedTransitions = TRANSITIONS.get(currentState);
if (allowedTransitions == null || !allowedTransitions.containsKey(event)) {
throw new IllegalStateException("非法状态转换: " + currentState + " + " + event);
}
OrderState nextState = allowedTransitions.get(event);
// 乐观锁更新
int rows = orderMapper.updateStatus(orderId, nextState.name(), order.getVersion());
if (rows == 0) {
throw new OptimisticLockException("并发冲突,请重试");
}
}Mapper
@Update("UPDATE orders SET status = #{status}, version = version + 1 " +
"WHERE order_id = #{orderId} AND version = #{version}")
int updateStatus(@Param("orderId") String orderId,
@Param("status") String status,
@Param("version") int version);附录:滑动窗口限流 Redis 实现完整版
@Component
public class RateLimitingFilter implements Filter {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final int MAX_REQUESTS = 100;
private static final long WINDOW_SIZE = 60; // 秒
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String apiKey = httpRequest.getHeader("X-API-KEY");
if (!isAllowed(apiKey)) {
HttpServletResponse httpResponse = (HttpServletResponse) response;
httpResponse.setStatus(429);
httpResponse.getWriter().write("Rate limit exceeded");
return;
}
chain.doFilter(request, response);
}
private boolean isAllowed(String apiKey) {
String key = "rate:" + apiKey;
long now = System.currentTimeMillis();
long windowStart = now - WINDOW_SIZE * 1000;
// Lua 脚本保证原子性
String script =
"redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1]) " +
"local count = redis.call('ZCARD', KEYS[1]) " +
"if count < tonumber(ARGV[2]) then " +
" redis.call('ZADD', KEYS[1], ARGV[3], ARGV[4]) " +
" redis.call('EXPIRE', KEYS[1], ARGV[5]) " +
" return 1 " +
"else " +
" return 0 " +
"end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(key),
String.valueOf(windowStart),
String.valueOf(MAX_REQUESTS),
String.valueOf(now),
String.valueOf(now) + ":" + UUID.randomUUID(),
String.valueOf(WINDOW_SIZE)
);
return result != null && result == 1;
}
}附录:EasyExcel 百万级数据导入完整方案
核心思路
- EasyExcel 逐行读取(Listener 模式),不会把整个文件加载到内存
- 每读取 N 条数据提交一次批量插入任务到线程池
- 线程池并行执行批量插入
实现代码
// 自定义读取监听器
public class DataImportListener extends AnalysisEventListener<DataDTO> {
private static final int BATCH_SIZE = 5000;
private List<DataDTO> batch = new ArrayList<>(BATCH_SIZE);
private ExecutorService executor;
private DataMapper dataMapper;
public DataImportListener(ExecutorService executor, DataMapper dataMapper) {
this.executor = executor;
this.dataMapper = dataMapper;
}
@Override
public void invoke(DataDTO data, AnalysisContext context) {
batch.add(data);
if (batch.size() >= BATCH_SIZE) {
submitBatch();
}
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
if (!batch.isEmpty()) {
submitBatch(); // 处理最后一批
}
}
private void submitBatch() {
List<DataDTO> toInsert = new ArrayList<>(batch);
batch.clear();
executor.submit(() -> dataMapper.batchInsert(toInsert));
}
}
// 调用方
public void importFile(MultipartFile file) {
ExecutorService executor = new ThreadPoolExecutor(
10, 20, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100),
new ThreadPoolExecutor.CallerRunsPolicy() // 队列满时主线程执行
);
EasyExcel.read(file.getInputStream(), DataDTO.class,
new DataImportListener(executor, dataMapper))
.sheet()
.doRead();
}MyBatis 批量插入
<insert id="batchInsert" parameterType="java.util.List">
INSERT INTO data_table (col1, col2, col3) VALUES
<foreach collection="list" item="item" separator=",">
(#{item.col1}, #{item.col2}, #{item.col3})
</foreach>
</insert>注意事项
- BATCH_SIZE 建议 3000-5000,太大 SQL 会超长
- 线程池队列用有界队列 + CallerRunsPolicy 避免 OOM
- 如果有唯一键校验需求,用 INSERT IGNORE 或先查再插
- 大事务问题:每批独立事务,避免一个超大事务
附录:EasyExcel 大文件导出方案
问题分析
POI 导出问题:
- 数据全部加载到内存 → OOM
- 单线程写入 → 超时
解决方案
public void exportLargeData(HttpServletResponse response) {
response.setContentType("application/vnd.ms-excel");
response.setHeader("Content-Disposition", "attachment;filename=export.xlsx");
ExcelWriter writer = EasyExcel.write(response.getOutputStream(), DataVO.class).build();
WriteSheet sheet = EasyExcel.writerSheet("数据").build();
int pageSize = 10000;
int pageNo = 1;
while (true) {
List<DataVO> data = dataService.queryPage(pageNo, pageSize);
if (data.isEmpty()) break;
writer.write(data, sheet);
pageNo++;
}
writer.finish();
}异步导出方案(超大数据量)
// 1. 接口立即返回任务 ID
public String submitExport(ExportRequest request) {
String taskId = UUID.randomUUID().toString();
exportTaskMapper.insert(new ExportTask(taskId, "PROCESSING"));
executor.submit(() -> doExport(taskId, request));
return taskId;
}
// 2. 异步执行导出,写入 OSS
private void doExport(String taskId, ExportRequest request) {
try {
String filePath = generateExcel(request);
String ossUrl = ossClient.upload(filePath);
exportTaskMapper.updateSuccess(taskId, ossUrl);
// 通知用户(钉钉/邮件/站内信)
} catch (Exception e) {
exportTaskMapper.updateFailed(taskId, e.getMessage());
}
}
// 3. 用户查询导出状态并下载
public ExportTask getExportStatus(String taskId) {
return exportTaskMapper.selectById(taskId);
}附录:Redis ZSET 排行榜进阶用法
按时间维度的排行榜
// 日榜 key 包含日期
String dailyKey = "rank:sales:" + LocalDate.now();
// 增加分数
redisTemplate.opsForZSet().incrementScore(dailyKey, productId, amount);
// 设置过期时间(保留 7 天)
redisTemplate.expire(dailyKey, 7, TimeUnit.DAYS);
// 获取日榜 Top 10
Set<TypedTuple<String>> top10 = redisTemplate.opsForZSet()
.reverseRangeWithScores(dailyKey, 0, 9);周榜/月榜的实现
使用 ZUNIONSTORE 合并多天的数据:
// 合并最近 7 天的日榜为周榜
String weeklyKey = "rank:sales:weekly";
String[] dailyKeys = new String[7];
for (int i = 0; i < 7; i++) {
dailyKeys[i] = "rank:sales:" + LocalDate.now().minusDays(i);
}
redisTemplate.opsForZSet().unionAndStore(dailyKeys[0],
Arrays.asList(Arrays.copyOfRange(dailyKeys, 1, 7)), weeklyKey);分页查询
// 获取第 2 页(每页 10 条)
long start = 10;
long end = 19;
Set<TypedTuple<String>> page2 = redisTemplate.opsForZSet()
.reverseRangeWithScores(key, start, end);附录:TTL (TransmittableThreadLocal) 原理详解
为什么 InheritableThreadLocal 不够用
InheritableThreadLocal 只在创建子线程时拷贝父线程的值。线程池中线程是复用的,不会每次创建新线程,所以第一次提交的任务能获取到值,后续任务获取的可能是旧值。
TTL 的工作原理
- 提交任务时:TTL 将当前线程的所有 TransmittableThreadLocal 值快照一份,附加到 Runnable/Callable 上
- 任务执行时:恢复快照值到执行线程的 ThreadLocal 中
- 任务执行完毕:恢复执行线程原来的 ThreadLocal 值
使用方式(三种)
// 方式1:装饰线程池(推荐)
ExecutorService ttlExecutor = TtlExecutors.getTtlExecutorService(originalExecutor);
// 方式2:装饰 Runnable
Runnable ttlRunnable = TtlRunnable.get(originalRunnable);
executor.submit(ttlRunnable);
// 方式3:Java Agent(无侵入)
// JVM 参数: -javaagent:transmittable-thread-local-agent.jar典型应用场景
- 链路追踪 ID(traceId)传递
- 用户上下文(userId、tenantId)传递
- 日志 MDC 上下文传递
- 权限信息传递
面试回答模板
在面试中被问到"你的项目有什么亮点/难点"时,可以按照以下模板回答:
场景(S): 我们的系统遇到了 XXX 问题/需要实现 XXX 功能...
方案(T+A): 经过调研我选择了 XXX 方案,核心原理是...关键实现点有...
效果(R): 最终取得了 XXX 效果(性能数据、解决了什么问题、避免了什么风险)
延伸: 如果面试官追问,可以展开讲技术选型的对比、踩过的坑、以及如果重新做会如何改进
附录:EasyExcel 大文件导出完整方案
POI 导出的问题
使用 Apache POI 导出大文件时常见两个问题:
- OOM:POI 的 XSSFWorkbook 会把所有数据加载到内存
- 超时:单线程写入大量数据耗时过长
EasyExcel 流式写出
EasyExcel 内部使用 SAX 模式解析和流式写出,内存占用恒定。
// 同步导出(适合数据量 < 50 万)
public void export(HttpServletResponse response) throws IOException {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition", "attachment;filename=data.xlsx");
ExcelWriter writer = EasyExcel.write(response.getOutputStream(), DataVO.class).build();
WriteSheet sheet = EasyExcel.writerSheet("数据").build();
int pageNo = 1;
int pageSize = 10000;
while (true) {
List<DataVO> data = dataService.queryByPage(pageNo++, pageSize);
if (data.isEmpty()) break;
writer.write(data, sheet);
}
writer.finish();
}异步导出 + OSS(适合数据量 > 50 万)
// Controller:提交导出任务
@PostMapping("/export")
public Result<String> submitExport(@RequestBody ExportRequest request) {
String taskId = exportService.submit(request);
return Result.success(taskId, "导出任务已提交,请稍后下载");
}
// Service:异步执行
@Async("exportExecutor")
public void asyncExport(String taskId, ExportRequest request) {
String localPath = "/tmp/export_" + taskId + ".xlsx";
try (ExcelWriter writer = EasyExcel.write(localPath, DataVO.class).build()) {
WriteSheet sheet = EasyExcel.writerSheet("数据").build();
int pageNo = 1;
while (true) {
List<DataVO> data = dataService.query(request, pageNo++, 10000);
if (data.isEmpty()) break;
writer.write(data, sheet);
}
}
// 上传到 OSS
String url = ossClient.upload(localPath);
exportTaskMapper.complete(taskId, url);
// 通知用户
notifyService.send(request.getUserId(), "导出完成", url);
}
// Controller:查询状态
@GetMapping("/export/status/{taskId}")
public Result<ExportTask> getStatus(@PathVariable String taskId) {
return Result.success(exportTaskMapper.selectById(taskId));
}附录:Token 防重完整方案
方案流程图
用户进入下单页 → 前端请求获取 Token → 后端生成 Token 存入 Redis → 返回 Token 给前端
↓
用户提交订单 → 前端携带 Token → 后端 Lua 脚本原子验证并删除 Token → 通过则处理订单
↓
用户重复提交 → Token 已被删除 → 验证失败 → 拒绝请求完整 Controller 实现
@RestController
@RequestMapping("/order")
public class OrderController {
@Autowired
private TokenService tokenService;
@Autowired
private OrderService orderService;
// 获取下单 Token
@GetMapping("/token")
public Result<String> getToken() {
String userId = UserContext.getCurrentUserId();
String token = tokenService.generate(userId);
return Result.success(token);
}
// 提交订单
@PostMapping("/submit")
public Result<String> submitOrder(@RequestBody OrderRequest request,
@RequestHeader("X-Order-Token") String token) {
String userId = UserContext.getCurrentUserId();
// 验证 Token
if (!tokenService.validate(userId, token)) {
return Result.fail("请勿重复提交");
}
// 处理订单
String orderId = orderService.create(request);
return Result.success(orderId);
}
}Lua 脚本保证原子性
@Service
public class TokenService {
private static final String TOKEN_PREFIX = "order_token:";
private static final long EXPIRE_SECONDS = 600; // 10 分钟
@Autowired
private StringRedisTemplate redisTemplate;
public String generate(String userId) {
String token = UUID.randomUUID().toString().replace("-", "");
redisTemplate.opsForValue().set(
TOKEN_PREFIX + userId, token, EXPIRE_SECONDS, TimeUnit.SECONDS);
return token;
}
public boolean validate(String userId, String token) {
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(TOKEN_PREFIX + userId),
token
);
return result != null && result == 1L;
}
}为什么用 Lua 脚本: GET + DEL 必须是原子操作。如果先 GET 再 DEL,两个线程可能都 GET 到相同的 Token 值导致重复处理。
附录:自定义注解 + AOP 完整示例
场景1:统一日志记录
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperationLog {
String value() default "";
}
@Aspect
@Component
public class OperationLogAspect {
@Around("@annotation(operationLog)")
public Object around(ProceedingJoinPoint pjp, OperationLog operationLog) throws Throwable {
long start = System.currentTimeMillis();
String methodName = pjp.getSignature().getName();
Object[] args = pjp.getArgs();
log.info("[{}] 方法: {}, 入参: {}", operationLog.value(), methodName, JSON.toJSONString(args));
try {
Object result = pjp.proceed();
long cost = System.currentTimeMillis() - start;
log.info("[{}] 方法: {}, 耗时: {}ms, 出参: {}",
operationLog.value(), methodName, cost, JSON.toJSONString(result));
return result;
} catch (Exception e) {
long cost = System.currentTimeMillis() - start;
log.error("[{}] 方法: {}, 耗时: {}ms, 异常: {}",
operationLog.value(), methodName, cost, e.getMessage());
throw e;
}
}
}
// 使用
@OperationLog("创建订单")
public OrderResult createOrder(OrderRequest request) {
// 业务逻辑
}场景2:统一缓存处理
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Cacheable {
String prefix();
long expireSeconds() default 300;
}
@Aspect
@Component
public class CacheAspect {
@Around("@annotation(cacheable)")
public Object around(ProceedingJoinPoint pjp, Cacheable cacheable) throws Throwable {
String key = buildKey(cacheable.prefix(), pjp.getArgs());
// 查缓存
Object cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
// 执行方法
Object result = pjp.proceed();
// 写缓存
if (result != null) {
redisTemplate.opsForValue().set(key, result,
cacheable.expireSeconds(), TimeUnit.SECONDS);
}
return result;
}
}面试回答模板
在面试中被问到"你的项目有什么亮点/难点"时,按照以下模板回答:
场景(S): 我们的系统遇到了 XXX 问题/需要实现 XXX 功能...
方案(T+A): 经过调研我选择了 XXX 方案,核心原理是...关键实现点有...
效果(R): 最终取得了 XXX 效果(性能数据、解决了什么问题、避免了什么风险)
延伸: 如果面试官追问,可以展开讲技术选型的对比、踩过的坑、以及如果重新做会如何改进