Redis 分布式缓存实战:Java 企业级用户认证与缓存高可用设计
Redis 在分布式锁、限流等场景中的应用大家已经聊得很多了。
今天聚焦另一个高频使用场景——「分布式缓存」,并结合用户登录模块来深入拆解实现细节。
假设公司有一款日活百万的 App,用户登录成功后获得一个 token,后续所有 HTTP 接口访问都需在请求头中携带该 token。
业界标准方案非常简单:登录成功后,直接将用户状态(Token + 核心信息)整体写入 Redis。后续每个请求带着 Token 进来,服务端直接查 Redis 进行校验,毫秒级响应,既快又稳。
✿一、具体业务流程
- 发放令牌:用户输入账号密码登录成功,服务端现场生成唯一 Token。
- 写入 Redis:将 Token 和对应的用户关键信息存入 Redis,并设置过期时间(此处采用 7 天)。
- 客户端保管:Token 返回给 App,App 自行存储本地(如 LocalStorage 等)。
- 携带令牌访问:App 所有后续接口请求,均在 Header 中带上该 Token。
- 拦截器校验:服务端拦截器获取 Token 后直接查询 Redis。查到则放行,查不到直接返回 401。

✿二、核心代码
技术栈采用 Spring Boot + RedisTemplate。为直观展示,Token 使用 UUID 生成。
实际项目中也可选择 JWT,但注销逻辑仍需借助 Redis 黑名单,各方案都有各自的取舍。
1、登录成功后生成 Token 并写入缓存
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* 认证服务 - 负责发放"通行证"
*/
@Service
public class AuthService {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private UserService userService;
public LoginResult login(String phone, String password) {
// 1. 校验用户身份
User user = userService.findByPhone(phone);
if (user == null) {
throw new RuntimeException("用户不存在");
}
// 密码必须使用 BCrypt 算法比对,切忌明文存储或自行编写加密逻辑,这是血的教训
if (!user.getPassword().equals(encryptPassword(password))) {
throw new RuntimeException("密码错误");
}
// 2. 生成 Token,添加 APP_ 前缀便于运维在 Redis 中快速识别
String token = "APP_" + UUID.randomUUID().toString().replace("-", "");
// 3. 构建缓存对象,仅存储最核心信息,越轻量越好
LoginCacheUser cacheUser = new LoginCacheUser();
cacheUser.setUserId(user.getId());
cacheUser.setPhone(user.getPhone());
cacheUser.setNickname(user.getNickname());
// 4. 写入 Redis,Key 命名需规范,便于后续清理与管理
// 格式:app:login:token:{token},有效期 90 天,具体天数可配置到 Apollo
String redisKey = "app:login:token:" + token;
redisTemplate.opsForValue().set(redisKey, cacheUser, 90, TimeUnit.DAYS);
// 5. 返回给前端
LoginResult result = new LoginResult();
result.setToken(token);
result.setUserInfo(new UserVo(user));
return result;
}
}
2、拦截器逻辑
App 端发起请求时,会在 Header 中携带 Token,例如:X-Auth-Token: APP_xxxxxxx。
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* 用户上下文拦截器
* 从请求头中提取 Token,解析后存入 ThreadLocal,供业务代码直接使用
*/
public class AppUserContextInterceptor implements HandlerInterceptor {
private static final Logger LOGGER = LoggerFactory.getLogger(AppUserContextInterceptor.class);
private static final String TOKEN_HEADER = "X-Auth-Token";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
try {
// 1. 从 Header 获取 Token
String token = request.getHeader(TOKEN_HEADER);
if (token == null || token.trim().isEmpty()) {
LOGGER.debug("请求头中未携带 Token,可能为匿名访问");
// 并非所有接口都需要登录,因此没有 Token 不直接拒绝,交由后续逻辑判断
return true;
}
// 2. 查询 Redis 获取用户信息
// 此处封装了查询方法,线上建议添加 Caffeine 本地缓存以降低 Redis 压力
LoginCacheUser cacheUser = getUserFromRedis(token);
if (cacheUser == null) {
LOGGER.warn("Token 在 Redis 中不存在,可能已过期: {}", token);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
// 3. 存入 ThreadLocal,后续业务代码可直接取出
AppUserContext.setCurrentUser(cacheUser);
LOGGER.info("请求 {} 已设置用户上下文: {}", request.getRequestURI(), cacheUser.getUserId());
return true;
} catch (Exception e) {
LOGGER.error("Token 解析异常", e);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 4. 请求结束后必须清理 ThreadLocal,否则线程池复用会导致数据串乱
// 曾因未清理,导致线程池中上一个请求的 userId 跑到下一个请求中,线上排查了三天才定位
AppUserContext.clear();
}
private LoginCacheUser getUserFromRedis(String token) {
String redisKey = "app:login:token:" + token;
return (LoginCacheUser) redisTemplate.opsForValue().get(redisKey);
}
}
务必注意:afterCompletion 中必须清理 ThreadLocal,这是血泪教训。
线上曾出现用户 A 的数据串入用户 B 的请求中,原因是线程池复用导致 ThreadLocal 残留。最终改用 TransmittableThreadLocal 解决。
3、AppUserContext 存储上下文
为支持线程池上下文传递,采用了阿里开源的 TransmittableThreadLocal。普通 ThreadLocal 在异步场景下会丢失数据,不少人都曾踩过这个坑。
1. 引入 Maven 依赖
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
<version>2.14.2</version>
</dependency>
2. 实现 AppUserContext
import com.alibaba.ttl.TransmittableThreadLocal;
import org.springframework.util.Assert;
/**
* 基于 TTL 的用户上下文
* 支持线程池、@Async 等异步场景
*/
public class AppUserContext {
private static final TransmittableThreadLocal<LoginCacheUser> CURRENT_USER =
new TransmittableThreadLocal<>();
private AppUserContext() {}
public static void setCurrentUser(LoginCacheUser user) {
Assert.notNull(user, "用户信息不能为 null");
CURRENT_USER.set(user);
}
public static LoginCacheUser getCurrentUser() {
LoginCacheUser user = CURRENT_USER.get();
if (user == null) {
throw new BusinessException("用户上下文不存在,请检查登录状态");
}
return user;
}
public static Long getCurrentUserId() {
return getCurrentUser().getUserId();
}
public static String getCurrentUserPhone() {
return getCurrentUser().getPhone();
}
// 安全获取,不抛出异常
public static Long getCurrentUserIdSafely() {
LoginCacheUser user = CURRENT_USER.get();
return user != null ? user.getUserId() : null;
}
public static void clear() {
CURRENT_USER.remove();
}
public static boolean hasUser() {
return CURRENT_USER.get() != null;
}
}
4、配置拦截器
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Resource
private AppUserContextInterceptor appUserContextInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(appUserContextInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/auth/login") // 登录接口自身验证,不被拦截
.excludePathPatterns("/api/auth/register")
.excludePathPatterns("/api/public/**"); // 公共接口也要放开
}
}
5、获取用户登录信息
引入 AppUserContext 后,可以在 Controller、Service 层非常优雅地获取用户登录信息。
@RestController
@RequestMapping("/api/user")
public class UserController {
@GetMapping("/profile")
public ApiResult<UserProfile> getProfile() {
// 直接获取当前用户 ID,无需每个接口都传递 userId
Long userId = AppUserContext.getCurrentUserId();
UserProfile profile = userService.getUserProfile(userId);
return ApiResult.success(profile);
}
}
@RestController
@RequestMapping("/api/order")
public class OrderController {
@GetMapping("/list")
public ApiResult<List<Order>> getOrderList() {
// 订单、收藏、评论……所有需要用户的接口都可以用这种模式
Long currentUserId = AppUserContext.getCurrentUserId();
List<Order> orders = orderService.findOrdersByUserId(currentUserId);
return ApiResult.success(orders);
}
}
这套方案已经在线上稳定运行两年,日活从几万逐步增长到几百万,Redis 始终表现可靠。
前期也曾踩过一些坑,如 ThreadLocal 未清理、Key 命名不规范、序列化方式选择不当等。修复这些隐患后,整个登录模块基本再未出现过问题。