
大家好,我是程序员卡卡罗特。之前我们聊过 Redis 在分布式锁、限流这些场景中的应用。
今天想和大家分享另一个高频使用场景——「分布式缓存」,并且结合用户登录这个场景去讲解。
想象一下,你们公司有个日活百万的 App,用户登入成功后,会获取一个token,后续调接口与后端交互,都是通过在请求头里面加上token来获取的。
业界,标准做法其实特简单:登录成功后,把用户状态(Token+核心信息)一把梭塞进Redis。后面每个请求都带着Token来,服务端直接找Redis验货,毫秒级响应,又快又稳。
1
发令牌:用户输入账号密码登录成功,服务端当场生成一个唯一Token
2
存Redis:把这个Token和对应的用户核心信息塞进Redis,设个过期时间(我们是7天)
3
客户端保管:Token返回给App,App自己存本地(LocalStorage或者啥都行)
4
携令牌访问:App后续所有接口请求,都在Header里带上这个Token
5
拦截器校验:服务端拦截器拿到Token,去Redis里查。查到就放行,查不到直接401

我们用的是Spring Boot + RedisTemplate,为了直观,Token用UUID生成。 真实项目里可以考虑JWT,但注销那套还是得靠Redis黑名单,各有各的坑。
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;
}
}
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,不然线程池复用会串数据
// 这个bug我们排查了三天,线程池里上一个请求的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
为了支持线程池传递上下文,我们用了阿里开源的TransmittableThreadLocal普通ThreadLocal在异步场景下会丢数据,这个坑不少人都踩过。
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
<version>2.14.2</version>
</dependency>
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;
}
}
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/**"); // 公共接口也要放开
}
}
有了AppUserContext后,我们可以在Controller、Service层非常优雅的获取用户登入信息。
@RestController
@RequestMapping("/api/user")
public class UserController {
@GetMapping("/profile")
public ApiResult<UserProfile> getProfile() {
// 这行代码写起来真爽,不用每个接口都传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命名不规范、序列化方式不当等等。但修完这些bug后,整个登录模块基本没出过问题。
我是程序员卡卡罗特,每天分享后端干货知识!如果觉得对你有用,点个赞+个关注在走呗🥳~