shopping-web项目八股分析

身份认证与权限校验

使用令牌技术实现身份验证,用自定栏截器完成用户认证,并结合 ThreadLocal进行截器校验,保障系统安全访间。

基于Spring Boot + JWT的完整身份认证示例,包含:

  • JWT生成和解析工具类
  • 认证拦截器(拦截请求验证JWT)
  • ThreadLocal存储当前用户信息
  • 配置拦截器注册
  • 简单的用户控制器示例

问题解析

1.JWT的组成

Header(头部):描述签名的元数据,包含alg(签名算法) HS256,RS256,typ(令牌的类型)通常为JWT,base64URL编码作为一个片段

Payload:存放业务数据和标准声明(Claims),

RFC定义iss(签发者)、sub(主题)、aud(受众)、exp(过期时间)、iat(签发时间)

Public Claims:用户自定义,但要避免冲突

Private Claims:双方约定的自定义字段(如 userIdroles

Signature(签名):

Base64Url(Header) + "." + Base64Url(Payload),再加上密钥,通过指定算法(如 HMAC SHA256)计算得到。

防止数据被篡改:接收方用相同算法和密钥校验签名

2.为什么要设置 exp,以及如何处理过期?

限制令牌的有效期,降低泄露后滥用风险,强制客户端定期获取新令牌,有助于权限变更及时生效。

自动处理过期:

自动拒绝:在解析 JWT 时,库(如 jjwt)会抛出 ExpiredJwtException,拦截器/过滤器捕获后返回 401 Unauthorized

Refresh Token 机制:

Access Token(短时有效)+ Refresh Token(长期有效且存放更安全)

Access Token 过期后,客户端用 Refresh Token 向专门接口换取新的 Access Token。

服务端黑名单:对关键场景,可在 Redis 等存储过期或手动废弃的 Token ID 列表,拦截时进一步比对。

3.对称加密(HS256)与非对称加密(RS256)的区别?

特性 HS256(对称) RS256(非对称)
密钥 同一个密钥用于签名和校验 使用私钥签名,公钥校验
安全性 只要共享密钥不会泄露;多服务时需安全分发密钥 私钥只在签发端保存,公钥可公开,泄露风险低
性能 HMAC 速度快 RSA 运算相对慢一些
应用场景 小规模或单体应用,部署简单 分布式/微服务或第三方验证场景,私钥保护更好

4.HandlerInterceptorFilter 的区别?

方面 Filter HandlerInterceptor
加载时机 最早,Servlet 容器启动时配置,位于 Spring MVC 之前 在 Spring MVC 内部,Controller 调用前后拦截
接口 javax.servlet.Filter org.springframework.web.servlet.HandlerInterceptor
职责 通用请求预处理:如日志、跨域、请求包装、字符编码 更贴近 MVC:可以访问 Handler(Controller)信息,做方法级鉴权
配置方式 @WebFilterFilterRegistrationBean 实现 WebMvcConfigurer#addInterceptors 注册
方法 doFilter preHandlepostHandleafterCompletion

5.如何配置某些路径放行?

比如我们让他不用登录就能看的一些页面,商品展示等等

在 Spring MVC 拦截器注册时,通过 excludePathPatterns(...)

1
2
3
4
5
6
registry.addInterceptor(jwtInterceptor)
.addPathPatterns("/api/**") // 拦截所有 /api/**
.excludePathPatterns(
"/api/auth/**", // 放行认证相关
"/swagger-ui/**", "/v3/api-docs" // 放行 Swagger
);

如果是 Filter,可在内部判断请求 URI,或在 FilterRegistrationBean 中设置 setUrlPatternssetOrder

6.Spring Boot 默认哪些静态资源路径?是否会被拦截?

  • 默认路径(classpath 下)
    • /static
    • /public
    • /resources
    • /META-INF/resources
  • 是否被拦截?
    • Spring MVC 的拦截器默认只拦截 /**,但静态资源由 ResourceHttpRequestHandler 处理,优先级高于一般的 Controller 调用。
    • 如果addInterceptors 中使用了拦截所有 /**,且拦截匹配到静态资源路径,就有可能拦截;但通常我们会在 excludePathPatterns("/**/*.js", "/**/*.css", "/**/*.png", ...") 或直接排除静态资源目录,确保静态资源正常加载。

7.为什么要在 afterCompletion 清理 ThreadLocal

ThreadLocal 存储的数据与当前线程绑定。

在高并发环境下,使用线程池复用线程,如果不手动清理,后续请求可能读取到上一个用户的信息,导致数据泄露安全漏洞

afterCompletion 保证在请求处理完毕后无论正常或异常,都能移除数据,防止内存泄露。

或者是如果不清理的话,因为是弱引用,ThreadLocal的key很容易被清理,其还有null的键,一直在那里呆着。所以要使用remove清理或者是,查找与当前线程关联的Map并将键值对设置为当前线程和null,Huoz是在finally中关闭ThreadLocal。

8.并发场景下 ThreadLocal 的适用与限制?

适用

  • 存放与当前请求、当前线程强关联的数据(如用户上下文、事务 ID)
  • 避免在方法间频繁传参

限制

  • 必须在请求结束后清理,否则线程复用会导致“上下文串库”
  • 不能跨线程(如异步执行、线程池任务)共享;如果在子线程里尝试取数据,需要显式传递或使用 InheritableThreadLocal(但要注意 GC 风险)
  • 大量数据存放会增加内存压力

或者是如果不清理的话,因为是弱引用,ThreadLocal的key很容易被清理,其还有null的键,一直在那里呆着。所以要使用remove清理或者是,查找与当前线程关联的Map并将键值对设置为当前线程和null,Huoz是在finally中关闭ThreadLocal。

9.如何安全地存储用户密码?

一定要哈希,切忌明文存储。

使用强单向哈希算法,推荐:

  • BCrypt(Spring Security 默认支持)
  • Argon2、PBKDF2

加盐:每个用户使用独立随机盐,防止彩虹表攻击。

适当迭代:增加计算成本,防止暴力破解。

10.为什么不要把敏感信息(如密码)放到 JWT 里?

JWT Payload 可被任意方 Base64Url 解码,不具备机密性。

即使签名防篡改,也无法防止任何人读取其中的明文数据。

应只放必要的非敏感标识(如 userIdroles),敏感数据应在后台按需查询

11.Token 在前端如何存储(localStorage vs HttpOnly Cookie)?

存储方式 优点 缺点
localStorage 简单易用,JS 可直接读写 易受 XSS 攻击:恶意脚本可读取并窃取 Token
HttpOnly Cookie JS 无法读取,能自动随请求带上,防止 XSS 需防范 CSRF(可配合 SameSite、双重提交 Cookie)

最佳实践

  • 推荐将 Access Token 存在 HttpOnly、Secure、SameSite=strict 的 Cookie 中;
  • 如果仍需在 JS 中访问,可用 Refresh Token 短时写入 localStorage,但严格防 XSS。
  • 同时配合 CSRF 令牌、CORS 白名单、内容安全策略(CSP)等。

1.你们的认证机制是怎么做的?为什么用 JWT?

前后端分离架构下使用 JWT 无状态令牌,用户登录后由服务端生成 Token,后续请求携带 Token 完成身份验证

与传统 Session 相比,JWT 不依赖服务端存储,更适合分布式微服务场景

签名机制可防止 Token 被伪造,Payload 可携带 userId 等信息减少数据库查询

2.如何设计 Token 刷新机制?

在拦截器中检查 Token 剩余有效时间

当 Token 临近过期(例如剩余 <5 分钟)时,自动生成新 Token 并通过响应头或 Cookie 返回

提高用户体验,避免频繁重新登录

可配合 Refresh Token 强化安全性

3.为什么使用 ThreadLocal?是否存在线程安全问题?

ThreadLocal 将用户信息与线程绑定,避免在每个方法中重复查询、传参,减少数据库/Redis 压力

在 Controller 层可直接通过 UserHolder.get() 获取用户信息,提升性能(如从 200ms 降到 20ms)

线程安全性保障

  • 每个线程有独立副本
  • 使用线程池时必须手动 remove(),否则用户数据串用 → 安全隐患

限制

  • 必须在请求结束后清理,否则线程复用会导致“上下文串库”
  • 不能跨线程(如异步执行、线程池任务)共享;如果在子线程里尝试取数据,需要显式传递或使用 InheritableThreadLocal(但要注意 GC 风险)
  • 大量数据存放会增加内存压力

或者是如果不清理的话,因为是弱引用,ThreadLocal的key很容易被清理,其还有null的键,一直在那里呆着。所以要使用remove清理或者是,查找与当前线程关联的Map并将键值对设置为当前线程和null,Huoz是在finally中关闭ThreadLocal。

4.为什么要使用两个拦截器?不能一个实现所有功能吗?

职责单一,遵循 单一职责原则,代码更清晰、可维护性更好

一级负责认证,二级负责续签,分层逻辑解耦,便于扩展和测试

也方便后期引入更多层(如角色校验、日志记录等)

5.Token 在前端如何存储?如何防止被盗用?

推荐使用 HttpOnly + Secure 的 Cookie 存储 Token,防止 JS 脚本读取(防 XSS)

结合 SameSite=Strict 属性防止 CSRF 攻击

避免把 Token 存在 localStorage 中,localStorage 易被 XSS 攻击读取

可选使用双 Token:Access Token + Refresh Token,提升安全性

6.为什么要使用 Access Token 和 Refresh Token 双 token?

Access Token 短期有效,暴露风险小;

Refresh Token 保护用户无需频繁登录;

避免频繁验证数据库或 Redis,提高性能;

配合双拦截器自动刷新,增强用户体验。

7.Refresh Token 要不要存 Redis?

建议存 Redis:

  • 可主动登出/注销 refresh token;
  • 可强制下线;
  • 可限制一个用户只有一个有效 refresh token;
  • 提高安全性(Refresh Token 不应被频繁验证,但一旦泄露影响很大)。

8.Refresh Token 过期后怎么办?

前端收到 401 后无法再自动续签;

引导用户重新登录;

后端返回特定错误码区分 access 与 refresh 失效。

9.如何防止 Refresh Token 被盗用?

限制 IP、设备标识(User-Agent)等;

加入 Redis 黑名单机制;

建议设置 HttpOnly Cookie 存储 refresh token,防止 XSS;

接口支档编写与测试

使用Swagger编写接口支档,开发过程中进行接口联调测试提升开发效率

Swagger 是一套 开放源代码项目,用于生成、描述、调用和可视化 RESTful 风格 Web 服务的工具集。主要包括:

  • Swagger UI:提供一个交互式文档页面,可以直接测试接口。
  • Swagger Editor:在线编辑 OpenAPI 规范文档。
  • Swagger Codegen:根据文档生成客户端 SDK 或服务端模板代码。

常见注解:

注解 作用
@Operation(summary = "...") 用于方法上,描述接口作用
@Parameter(name = "...") 用于方法参数上,描述参数
@Schema(description = "...") 用于实体类字段上,描述字段
@Tag(name = "...") 用于 Controller 上,分组描述
@RequestBody + @Schema 描述请求体
@ApiResponse(responseCode = "200", description = "...") 描述响应码与内容

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
@RequestMapping("/api/user")
@Tag(name = "用户接口", description = "用户相关接口文档")
public class UserController {

@PostMapping("/add")
@Operation(summary = "新增用户")
public ResponseEntity<String> addUser(@RequestBody @Parameter(description = "用户信息") UserDTO user) {
return ResponseEntity.ok("添加成功: " + user.getUsername());
}

@GetMapping("/{id}")
@Operation(summary = "根据ID获取用户")
public ResponseEntity<UserDTO> getUser(@PathVariable Long id) {
UserDTO user = new UserDTO();
user.setUsername("张三");
user.setAge(25);
return ResponseEntity.ok(user);
}
}

1.Swagger 有什么作用?使用它有什么优势?

Swagger 可以自动生成接口文档,支持接口调试,提升前后端联调效率。优势包括:

  • 接口文档自动同步,无需手写维护
  • 可视化交互式页面,便于测试接口
  • 支持导出 OpenAPI 规范,便于生成 SDK 或 Mock 接口

2.Swagger 在 Spring Boot 项目中如何接入?

使用 springdoc-openapi 依赖,添加依赖后即可自动扫描 @RestController 注解的方法生成接口文档,常用注解包括 @Operation@Schema@Parameter 等。

3.Swagger 与 Postman 有何区别?

Swagger 更适合开发阶段自动生成文档和接口调试;

Postman 更适合接口自动化测试、团队共享测试集合、压测脚本等;

两者可互补:Swagger 导出 OpenAPI 规范,Postman 可导入执行。

4.如何通过 Swagger 实现接口 Mock?

Swagger 本身不提供 Mock 功能,但可以通过:

  • SwaggerHub 提供在线 Mock;
  • 使用 Swagger JSON 配合工具如 Prism、[WireMock] 实现;
  • 本地模拟返回固定数据,用于前端调试。

5.MOCK是啥

用于在真实对象不可用、未完成或不方便调用时,使用伪造的“替代对象”来模拟其行为

本质:使用假的对象或返回结果,代替真实依赖或真实数据,方便开发和测试。

性能与部署优化:使用Redis缓存热门菜品数据居,应对高并发,减少数据库访尚,缩短接口响应时间:配置g作为日P服务器,外理静态资源赔部置、反同代单及负的律,提升系统稳定性与响应能力

八股回答

1.优惠劵的超卖重复问题,使用lua脚本保持原子性

使用 Lua 脚本实现购买优惠券的原子性操作,常用于防止并发问题,比如超卖重复领取等。

Redis 是单线程的,它执行 Lua 脚本时会串行执行整个脚本内容,所以可以用 Lua 脚本在 Redis 内部实现多个命令的原子执行,避免并发问题。

场景需求:

  • 用户未领取过优惠券
  • 库存 > 0
  • 扣减库存,记录用户领取状态

stock:coupon:{couponId}:优惠券库存

user:coupon:{couponId}:领取记录的 Set(存储 userId)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- KEYS[1]:库存Key
-- KEYS[2]:领取记录Key
-- ARGV[1]:用户ID

-- 判断是否领取过
if redis.call('sismember', KEYS[2], ARGV[1]) == 1 then
return 0 -- 已领取
end

-- 获取库存
local stock = tonumber(redis.call('get', KEYS[1]))
if stock <= 0 then
return 1 -- 库存不足
end

-- 扣减库存
redis.call('decr', KEYS[1])
-- 记录用户
redis.call('sadd', KEYS[2], ARGV[1])
return 2 -- 成功

1
2
3
4
5
6
7
8
9
10
11
String script = "..."; // 上面的 Lua 脚本内容
List<String> keys = Arrays.asList("stock:coupon:1001", "user:coupon:1001");
List<String> args = Arrays.asList("user123");

Object result = jedis.eval(script, keys, args);
switch ((Long) result) {
case 0: System.out.println("重复领取"); break;
case 1: System.out.println("库存不足"); break;
case 2: System.out.println("领取成功"); break;
}

优惠券库存减 1 关键是要防止超卖,即多个请求同时扣库存导致数据错乱。

一种常见做法是使用 乐观锁(CAS)

  • 数据表中给优惠券记录加个 版本号(version)更新时间戳
  • 更新库存时带上版本号或时间戳条件,例如:
1
2
3
UPDATE coupon SET stock = stock - 1, version = version + 1 
WHERE coupon_id = ? AND stock > 0 AND version = ?

2.lua脚本执行异常

提到 Lua 脚本执行异常时因为库存字段不可见被拦截,这里具体是指 Redis 的权限问题,还是脚本中对字段的引用方式有问题?比如有没有检查过 Lua 脚本里的 key 是否和 Redis 中存储的一致

脚本执行超时 (Timeout):

Lua 脚本的执行是原子性的,会阻塞 Redis 实例。如果脚本执行时间过长,会导致其他客户端请求被阻塞,甚至客户端连接超时。Redis 有一个 lua-time-limit 配置(默认为 5 秒),超出这个时间脚本就会被终止。

保持脚本短小精悍: Lua 脚本应该只包含最核心、需要原子性执行的逻辑。复杂的、非原子性的业务逻辑应该在客户端侧完成。

避免昂贵的操作: 避免在脚本中执行 KEYSSMEMBERSHGETALL 等会遍历大量数据的命令。如果确实需要处理大量数据,考虑将大键拆分成小键,或者在客户端分批处理。

增量处理: 如果必须在脚本中处理大量数据(例如清理过期键),考虑使用 SCAN 命令的变体(HSCAN, SSCAN, ZSCAN)进行增量迭代,并将脚本设计为分批执行。

监控 slowlog: 检查 Redis 的 slowlog,看是否有 EVALEVALSHA 命令长时间执行。

3.使用 Redis+Lua 做原子操作时,网络延迟对 CAS 操作的影响及如何保证并发原子性?

Lua 脚本在 Redis 中执行是单线程、原子性的。

  • 只要你把 CAS(Compare-And-Set)逻辑写在 Lua 脚本中,整个脚本执行期间不会被其他命令打断。
  • 因此,从 Redis 侧来看,无论多少并发请求同时来,脚本内的检查+更新都是串行执行且原子完成

网络延迟影响主要体现在客户端发送请求和接收结果的时延,但不影响 Redis 端脚本执行的原子性。

  • 例如两个客户端几乎同时发送请求,Redis 会顺序执行两个 Lua 脚本,避免数据竞争。
  • 但网络延迟可能导致客户端感知延迟或重试。

保证多个并发请求下原子性的建议:

  • 务必把校验库存和扣减库存的操作写到同一个 Lua 脚本中,保证原子执行。
  • 避免客户端先读库存再决定是否写,因读写分开易出现竞态条件。
  • 可以结合 Redis 的 WATCH + MULTI/EXEC 事务机制,但比不上 Lua 脚本原子性简单可靠。
  • 另外,设置合理超时和重试机制,防止网络异常导致操作丢失。

4.在 “校园餐饮系统” 项目里,你用 AOP 实现了操作日志自动记录,讲讲 AOP 切点是怎么定义的,怎么确保只拦截到需要记录日志的关键业务方法,又不会过度拦截影响系统性能呀?

我们系统中使用了 Spring AOP 来统一记录用户的操作日志,包括登录日志和业务行为日志。整体设计思路是基于 自定义注解 + 切面编程 + 策略模式 来实现灵活、可扩展的日志记录体系。

我们定义了一个自定义注解 @LogRecord,用于标记需要记录的操作方法,注解中可以配置以下元信息:

1
2
3
4
5
6
7
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogRecord {
LogType type(); // 登录日志 or 业务日志
String action(); // 操作行为描述,如 "删除用户"
}

切面实现日志拦截逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Aspect
@Component
public class LogAspect {

@Around("@annotation(logRecord)")
public Object recordLog(ProceedingJoinPoint pjp, LogRecord logRecord) throws Throwable {
// 1. 获取方法签名与参数
MethodSignature signature = (MethodSignature) pjp.getSignature();
Method method = signature.getMethod();

// 2. 根据注解元信息决定日志类型
LogType type = logRecord.type();
String action = logRecord.action();

// 3. 执行方法并记录执行结果
Object result = pjp.proceed();

// 4. 将日志对象封装后交由策略类处理
LogContext context = new LogContext(...);
logStrategyFactory.getStrategy(type).record(context);

return result;
}
}

日志记录策略设计(策略模式):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public interface LogStrategy {
void record(LogContext context);
}

@Component
public class LoginLogStrategy implements LogStrategy {
public void record(LogContext context) {
// 记录用户登录成功/失败,IP、时间等
}
}

@Component
public class BizLogStrategy implements LogStrategy {
public void record(LogContext context) {
// 记录用户执行的业务操作,如“删除用户”
}
}

策略选择通过 LogStrategyFactory 实现,支持按日志类型动态扩展。

如何保证只拦截关键方法、避免性能问题:

只对使用 @LogRecord 注解的方法进行拦截,确保业务无关方法不被扫描,避免性能浪费;

切面逻辑尽量精简,只做元信息解析和日志上下文构建,真正的日志落盘交给异步任务处理或消息队列;

对不需要记录的查询类接口,不加注解,完全绕过切面执行,保障系统整体响应效率。

日志可选择输出到文件(使用 SLF4J + Logback),或存入数据库 / Elasticsearch,供后期审计;

特殊场景下(如操作异常),支持记录异常栈与执行耗时。

追问:::

  • :@Around 和 @Before 有什么区别,为什么你选用 Around?
  • :Around 能拿到方法执行前后的控制权,包括返回值和异常,更适合记录执行结果和耗时。
  • :怎么处理日志失败、落盘慢的问题?
  • :日志落盘可异步执行或通过 MQ 异步解耦,避免阻塞主流程。

5.在 “校园餐饮系统” 项目里,你提到用了 Redis 存储 token 缓解压力,能讲讲具体怎么设计 key 的结构,以及如何保障 token 存储和读取的高效性与安全性不?

我们使用 Redis 存储用户的 refresh token,以提升系统性能并减少数据库压力,整体设计分为三部分:Key 设计、存储结构、安全机制

结构设计:

1
token:refresh:{userId}

这种格式能明确标识 token 类型和所属用户,保证唯一性和可读性,避免 key 冲突。

Value 的存储结构:

我们使用 Redis 的 字符串结构(String)哈希结构(Hash) 存储 refresh token,取决于业务是否需要额外附加字段(如生成时间、IP、设备信息)。

1
HSET token:refresh:123 userId 123 token abc123 createdAt 2025-06-19

设置合理的过期时间(如 7 天),使用 EXPIRESETEX 命令控制 token 生命周期。

Token 的生成与安全性保障:

Token 使用 JWT 生成,签名采用 HS256 算法(HMAC + SHA256),密钥保存在服务端配置中心或安全的 KMS(Key Management Service)中。

JWT 本身包含用户信息、过期时间等,生成后只将 access token 下发到前端,refresh token 则存于 Redis 中服务端管理,防止伪造。

认证流程:

登录时生成 access + refresh token:

  • access token 返回给前端;
  • refresh token 存入 Redis,key 为 token:refresh:{userId}

前端 token 过期后,携带 refresh token 请求刷新接口

后端校验 Redis 中的 refresh token,校验通过后重新生成新 token 对。

提前防止问题:

防缓存穿透:使用 布隆过滤器 记录有效用户 ID 或常见请求 Key,防止恶意请求频繁查询 Redis 失败并打爆数据库。

防击穿:为热点用户的 token 设置合理 TTL,避免同一时间大批用户同时失效。

防雪崩:TTL 设置加入随机因子,避免大批 key 同时过期。

权限校验:请求时验证 token 是否属于当前 userId,防止越权操作。

6.那么优惠劵的发放呢?一些列的问题呢

✅ 一、关于优惠券类型的常见问题

❓1. 如何区分和应用不同类型的优惠券逻辑?

面试点:策略模式、优惠金额计算方式。

答法

我们用策略模式来封装每种优惠券的优惠逻辑(如满减、折扣、无门槛)。每种类型对应一个策略类,实现统一的接口方法 calculateDiscount(OrderInfo order),这样能在运行时动态选择策略,便于扩展和维护。


✅ 二、关于接口设计的追问

❓2. 为什么接口是这样的?有没有考虑幂等性和安全性?

  • POST /use 接口可能会被重复调用,是否幂等
  • 有没有做权限控制,防止其他用户伪造请求?

答法

在使用优惠券时,为防止重复使用,我们使用 Redis + Lua 实现原子操作,同时标记优惠券为“已使用”。此外接口采用登录态校验,验证当前操作用户是否拥有该优惠券。敏感操作都要求用户登录,并记录操作日志。


✅ 三、关于并发处理和限领逻辑

❓3. 如果多用户并发领取优惠券,如何防止超发?

答法

发放流程中,我们使用 Redis 的 DECR 或 Lua 脚本实现优惠券库存的原子扣减,保证不会发放超过设定数量。同时加锁防止并发条件下库存扣减不一致。


❓4. 如何限制每个用户只能领取一次?

答法

Redis 中设置一个标识键:user:coupon:received:{couponId}:{userId},发放前先判断这个键是否存在,避免重复发放。也可以结合布隆过滤器提前过滤无效请求。


✅ 四、关于时间与状态校验

❓5. 限时券怎么判断是否有效?服务端怎么处理时间逻辑?

答法

每张优惠券记录中保存有效时间范围。使用时,服务端比对当前时间是否在有效期内。同时定时任务每天扫描过期优惠券,标记为“已过期”。


✅ 五、关于优惠券使用过程的逻辑判断

❓6. 使用时如果有多个优惠券,怎么选最优?

答法

前端可以请求一个“推荐最优券”的接口,我们在服务端遍历用户可用券,调用各个策略类计算预期优惠,返回最高优惠金额对应的券。


✅ 六、关于优惠券与订单系统结合问题

❓7. 如果下单失败了,优惠券是否要恢复?

答法

下单失败(如支付失败、库存不足)时,我们会在事务回滚后将优惠券状态重置为“未使用”。为了避免并发问题,使用分布式事务(如消息队列、TCC)或 Redis 回滚标记。


✅ 七、进阶设计问题

❓8. 优惠券支持“异步发放”和“定时生效”吗?

答法

是的,可以结合定时任务(如 Quartz 或 Spring Schedule)实现定时发放;发放时间和有效时间字段分离,发放时写入 Redis 延时队列,当生效时间到达时插入到用户优惠券表中。


✅ 八、可能让你设计代码结构的问题

❓9. 优惠券模块的结构划分是怎样的?

答法(示意):

1
2
3
4
5
- entity(Coupon, UserCoupon)
- controller(CouponController)
- service(CouponService、UseStrategy接口及实现)
- repository(MyBatis-Plus Mapper)
- util(金额计算工具类)

✅ 九、安全性问题

❓10. 用户伪造优惠券 ID,试图套用别人的优惠券怎么办?

答法

每次使用时不仅要校验优惠券 ID 是否存在,还要校验当前登录用户是否是该优惠券持有人,只有归属校验通过才能使用。

7.看你在优惠劵的发放的时候使用限流,怎么设计的

我们在优惠券领取接口中用 AOP + 令牌桶实现限流

  • 注解定义@RateLimit 注解标注需要限流的方法,参数包括 limitCount=5(每分钟 5 次)、time=1(时间单位分钟)、keyType="USER_IP"(按用户 IP+ID 限流)。
  • 切面实现:通过 @Around 切面拦截注解方法,获取参数后生成唯一限流 key(例如 rate_limit:user_123:192.168.1.1),然后调用 Redis 令牌桶服务校验。
  • Redis 令牌桶:用 Lua 脚本实现原子性校验,核心是根据时间戳计算可生成的新令牌数,不足时拒绝请求。比如用户抢优惠券时,同一 IP+ID 每分钟最多 5 次请求,防止恶意刷接口。
  1. 防重复领取:避免用户短时间内多次点击接口,导致优惠券超发
  2. 保护服务端:峰值流量时限制请求频率,防止 Redis 或数据库被击穿。线上遇到过同一 WiFi 下多个用户被误限的情况,后来把 key 改为`IP+用户ID,减少了误判率。”

切面类如何获取注解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Aspect
@Component
public class RateLimitAspect {
@Pointcut("@annotation(com.example.RateLimit)")
public void rateLimitPointcut() {}

@Around("rateLimitPointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
// 获取方法上的注解
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
RateLimit rateLimit = method.getAnnotation(RateLimit.class);
int limitCount = rateLimit.limitCount();
long time = rateLimit.time();
String keyType = rateLimit.keyType();

// 生成限流key(IP+用户ID)
String key = generateKey(joinPoint, keyType);
// 调用Redis令牌桶校验
boolean allow = redisBucketService.tryAcquire(key, limitCount, time, TimeUnit.MINUTES);
if (!allow) {
throw new RuntimeException("请求频繁,请稍后再试");
}
return joinPoint.proceed();
}
}

为什么选令牌桶而不是漏桶?

令牌桶适合突发流量,漏桶适合平滑流量

使用redis+lua原子的存储令牌桶,使用时间戳来校验。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 令牌桶Lua脚本(简化版)
local key = KEYS[1] -- 限流key
local capacity = tonumber(ARGV[1]) -- 桶容量
local rate = tonumber(ARGV[2]) -- 令牌生成速率(个/秒)
local now = tonumber(ARGV[3]) -- 当前时间戳
local requested = tonumber(ARGV[4]) -- 请求令牌数

-- 读取上次更新时间和剩余令牌数
local last = redis.call('hget', key, 'last')
local tokens = tonumber(redis.call('hget', key, 'tokens') or 0)
last = last or now

-- 计算可生成的新令牌数
local delta = now - last
local newTokens = math.min(capacity, tokens + delta * rate)
if newTokens >= requested then
-- 够发令牌,更新状态
redis.call('hset', key, 'last', now)
redis.call('hset', key, 'tokens', newTokens - requested)
return 1 -- 允许访问
else
return 0 -- 拒绝访问
end

8.你项目里用了JWT加双 Token,具体是怎么设计的?两个 Token 各自的作用是什么?拦截器又分别处理什么逻辑?比如有没有考虑过 Token 过期、刷新机制,或者防重放攻击的问题?

token:

  • access token(7 天有效期):放在请求头,用于日常接口认证,用 HS256 加密(密钥服务端持有);放在前端的localStorage/jwt
  • refresh token(15 天有效期):存在 Redis,值是随机字符串,且绑定用户 ID ,防止盗用。放在redis中
安全措施 说明
使用 HTTPS 防止中间人劫持 token
access token 只读 JWT 使用 HS256 加密,防篡改
refresh token 存 Redis 结合用户 ID 存储,支持设置 TTL,有效控制生命周期
黑名单机制(登出) 用户退出登录时可删除 Redis 中 refresh token,立即失效
防止多端同时登录 Redis 可使用 userId 为 key,限制 refresh token 单个登录会话
Token 刷新接口限流 防止 refresh token 被滥用(加防重放、限频)
Token 刷新重发保护 Redis 设置短期 refresh token 使用标记,防止并发重复刷新

退出策略:

用户点击退出:

  • 删除 Redis 中的 refresh token(或设置为无效标记);
  • access token 因为是 JWT,不可被撤销,可考虑实现黑名单机制(例如 Redis 中维护一张 token 黑名单);

黑名单适用于高安全场景(如后台管理系统),但会略增加接口访问时的 Redis 查询压力。

jwt拦截器:

  1. 认证拦截器:先检查请求头是否有 access token,没有就返回 401;有则解析 token,校验用户信息是否存在。
  • 第一个拦截器先校验请求头是否有 access token,没有的话直接返回 401;第二个拦截器在 access token 有效时,额外检查是否快过期(比如剩余时间 < 10 分钟),如果是就用 refresh token 去 Redis 换全新的 access token 和 refresh token(这里要注意刷新时的原子性,避免并发问题)。
  1. 刷新拦截器:如果 access token 剩余有效期 < 10 分钟,就用请求头中的 refresh token 去 Redis 校验:
    • 校验通过的话,生成新的 access token 和 refresh token(新 refresh token 会覆盖 Redis 中的旧值,保证单设备登录);
    • 校验失败的话,直接让用户重新登录。

危险:

前端或攻击者拿到密钥(如部署泄露、浏览器调试泄露),就可以伪造合法 token。

token 内容是可解密的,攻击者可猜测或修改 payload,然后重新签名。

如果服务端未验证签名(有些误用场景会只解析不校验),更容易被利用。

但还是sh256更快,计算算开销低;对称密钥管理更简单,适用于内部系统或中小型项目;

开发成本更低,不涉及证书管理、公私钥分发;

措施 说明
✅ 使用强密钥 至少 256 bit 的复杂密钥,不可硬编码进前端或代码中
✅ 启用 HTTPS 防止 token 被中间人劫持
✅ token 签名校验 每次都校验 JWT 的签名,防止伪造
✅ 缩短 access token 有效期 缩小攻击窗口
✅ refresh token 存 Redis 结合用户 ID 防重放、防伪造
✅ 黑名单机制 + 登出清除 登出时使 refresh token 无效
✅ 检查 UA/IP 等指纹 防止 token 被别人拿去复用

9.校园餐饮系统”中用 Redis + Lua 脚本 解决支付与库存扣减一致性问题的回答

扣减库存(商品服务)

扣除余额或生成支付单(支付服务)

创建订单记录(订单服务)

我们在库存服务中使用了 Redis + Lua 脚本 来实现本地原子扣减库存 + 用户去重校验,以解决高并发下的库存超卖问题。

为什么使用 Lua 脚本?
因为 Redis 本身是单线程的,Lua 脚本在 Redis 内部执行时具备原子性:整个脚本要么全部成功、要么全部失败(回滚)。我们把两个关键操作封装为一体执行:

  • 检查库存是否充足
  • 判断用户是否已领取(防止重复操作)
  • 若都满足,原子扣减库存并记录领取状态

脚本大致逻辑如下(伪代码):

1
2
3
4
5
6
7
8
9
if redis.get(stock_key) <= 0 then
return 0 -- 库存不足
end
if redis.sismember(user_set, user_id) then
return 1 -- 已领取过
end
redis.decr(stock_key)
redis.sadd(user_set, user_id)
return 2 -- 扣减成功

✅ 该方式解决的是单点库存系统下的原子性问题,但它本身不等同于分布式事务

用来防止 支付成功 → Redis 脚本执行失败 → 用户实际支付了,但库存未扣 → 数据不一致!

但若中间失败就可能导致 Redis 与 MySQL 数据不一致。我们改进方式为:

  • 对写数据库后的数据变化,通过 binlog 或 MQ 投递通知 Redis 更新;
  • 或者在数据变更时 先删除缓存(Cache Aside 模式),由下次读时自动加载新数据;
  • 加入失败重试机制,确保最终一致性。

10.为什么要用 ThreadLocal 保存用户信息?和直接传参或放到全局变量有什么区别?

我们将登录成功后的用户信息(如 userId、权限)解析出来并存入 ThreadLocal<UserDTO>

  • 这样做可以避免频繁从数据库或 Redis 获取用户信息;
  • 在整个线程生命周期内(一次请求),任何地方都可以通过 ThreadLocal 拿到用户信息,避免层层传参;
  • 比如日志记录、数据权限控制、业务层注入当前用户等场景都能简化处理。

和全局变量的区别是,ThreadLocal 保证每个线程有独立副本,不会产生线程安全问题。

11.你限流是怎么做的?为什么选 Bucket4j?AOP 在这里起了什么作用?

我们使用的是 基于 AOP 的注解式限流方案

  • 自定义注解 @RateLimit(...),在需要限流的接口上标记;
  • 使用 Spring AOP 拦截该注解方法,提取用户标识(IP 或用户 ID)+ 请求路径作为限流 key;
  • 在拦截逻辑中使用 Bucket4j + Redis backend 构建令牌桶,对该 key 做分布式限流。

为什么选 Bucket4j?

  • 支持多种限流算法(令牌桶、漏桶)
  • 支持 Redis、Hazelcast 等分布式存储
  • 语义丰富,可设置恢复速率、最大突发数等参数

优点:

  • 分布式环境可用(多实例共享限流状态)
  • AOP 实现接入方便、解耦代码

12.为什么发放优惠券要用 Lua 脚本?脚本中都做了哪些事?你如何保证幂等性和原子性?

Lua 脚本用于 Redis 中实现发券逻辑,是为了保证整个操作的原子性,避免并发发放导致超卖。

脚本逻辑主要包含:

  1. 检查库存是否大于 0;
  2. 判断当前用户是否已领取;
  3. 若未领取、库存充足,则:
    • 减库存;
    • 标记用户已领取;
    • 返回发放成功。

这些步骤在脚本中一次性执行,具备原子性,防止线程交错出现并发漏洞。

为保证幂等性,我们用 Redis 的 SETNX 操作或 SADD 判重集合,避免重复领取。

13.操作日志是怎么自动记录的?为什么要用 AOP 实现?

我们使用 AOP + 注解方式自动记录用户操作行为,无需每个业务手动写日志。

  • 自定义注解 @OperationLog(value="下单操作")
  • 通过 AOP 拦截这些方法,自动记录:
    • 用户 ID(从 ThreadLocal 拿)
    • IP 地址、请求时间、执行耗时
    • 方法名称、操作模块
    • 请求参数和响应结果(可脱敏)

优势:

  • 解耦业务逻辑和日志收集
  • 可灵活扩展(如写入 Elasticsearch、数据库、MQ)
  • 配合登录拦截器还能自动记录登录 IP、时间等行为

14.你说用 Nginx 解决了跨域问题,是怎么配置的?有没有考虑过 CORS 方案?

我们通过 Nginx 配置反向代理,将前端请求 /api/** 转发至后端服务,并统一处理跨域:

1
2
3
4
5
6
location /api/ {
proxy_pass http://backend-service/;
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods GET,POST,OPTIONS;
add_header Access-Control-Allow-Headers Authorization,Content-Type;
}

这样前端调用 /api/xxx 时,相当于同源请求,避免了浏览器 CORS 警告。

当然,在开发环境我们也启用了 Spring 的 CORS 配置(CorsFilter),用于本地调试跨域请求,生产环境由 Nginx 完成统一处理。

15.CORS跨域问题

一个页面发起的ajax请求,只能是与当前页域名相同的路径,这能有效的阻止跨站攻击。因此,跨域问题 是针对ajax的一种限制。

跨域:浏览器对于javascript的同源策略的限制。下面几种情况都属于跨域:

1
2
3
4
1、域名不同  www.jd.com 与 www.taobao.com
2、端口不同 www.jd.com:8080 与 www.jd.com:8081
3、二级域名不同 item.jd.com 与 miaosha.jd.com
4、http和https也属于跨域

CORS是一个W3C标准,全称是”跨域资源共享”(Cross-origin resource sharing)。它允许浏览器向跨源服务器,发出XMLHttpRequest请求,从而克服了AJAX只能同源使用的限制。

CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10

实现:

在网关(zuul)中编写一个配置类,并且注册CorsFilter:

SpringMVC已经帮我们写好了CORS的跨域过滤器:CorsFilter ,内部已经实现了刚才所讲的判定逻辑,我们直接用就好了。

服务端可以通过拦截器统一实现,不必每次都去进行跨域判定的编写。

在网关中创建一个CorsFilter的跨域访问过滤器类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Configuration
public class HandouCorsConfiguration {

@Bean
public CorsFilter corsFilter(){
//1.添加CORS配置信息
CorsConfiguration config = new CorsConfiguration();
//1) 允许的域,不要写*,否则cookie就无法使用了
config.addAllowedOrigin("http://manage.handou.com");
//2) 是否发送Cookie信息
config.setAllowCredentials(true);
//3) 允许的请求方式
config.addAllowedMethod("*");
// 4)允许的头信息
config.addAllowedHeader("*");

//2.添加映射路径,我们拦截一切请求
UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
configSource.registerCorsConfiguration("/**", config);

//3.返回新的CorsFilter.
return new CorsFilter(configSource);
}
}