问题:使用 Nginx 负载均衡时,用户的查询请求会分配到不同的JVM,当接收到用户请求时,JVM如何区分用户从而响应用户?
redis实现共享session登录
视频中采用的办法是:利用 redis 的 hash 结构,token 作为 key ,用户属性和属性状态分别作为 <field, value> 保存 然而:JVM 是如何区分用户的?比如:用户登录之后会进行抢购优惠券等活动,JVM在处理请求的时候,如何保证响应的是哪个用户呢?
1. token 与 userMap
UserServiceImpl.java
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误!");
}
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
return Result.fail("验证码错误");
}
User user = query().eq("phone", phone).one();
if (user == null) {
user = createUserWithPhone(phone);
}
String token = UUID.randomUUID().toString(true);
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
return Result.ok(token);
}
上述登录流程 login(…) 中校验手机号验证码通过之后,使用 UUID 随机生成了 token,然后拼接上前缀之后,作为key保存到 redis 中
String token = UUID.randomUUID().toString(true);
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
视频中采用的是验证码登录,每执行一次 login(…) 【点击 登录】都会执行生成验证码的逻辑。即无论是否是同一用户【手机号一致】,若是多次登录也会生成不同的token。
问题:同一个用户每次登录,有不同的token,对业务有影响吗? 测试结果:在 postman 中无论用哪个 token 抢购优惠券,都会使得该token对应的用户能够抢到优惠券。
问题分析:即使是不同的 token 里面存储的 userMap 都是一致的,在根据用户 id 抢购优惠券的时候,必然是同一个人在下单。
2. 登录手机号与userMap是怎么关联的?
2. 1 userMap 是怎么来的?
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
userMap,userDTO,user 三者之间的关系? ① userDTO 的作用:隐藏部分 user 属性 ② userMap :userDTO 保存在 redis 中的形式 即:保存到 redis 中的 userMap 是经过了从 user —> userDTO —> userMap 的转化。
2.2 user 是怎么来的?
数据库 tb_user 中的字段信息
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
String phone = loginForm.getPhone();
User user = query().eq("phone", phone).one();
if (user == null) {
user = createUserWithPhone(phone);
}
return Result.ok(token);
}
① user 是通过使用 “phone” 查询数据库获取的【首次登录数据库中没有记录,会新建用户并保存到数据库中】
② “phone” 是通过 Login(LoginFromDTO loginFrom) 传输的参数对象 loginFrom 获取的
2.3 loginFrom 怎么来的?
UserController.java
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
return userService.login(loginForm, session);
}
定位到Controller 层,涉及到与视图层的交互.
2.3.1 如何确定是前端哪个页面发送的请求呢?
由于对 SpringMVC 的知识点不了解,做以下测试进行分析 ① 点击 “发送验证码” UserController.java
@RestController
@RequestMapping("/user")
public class UserController {
...
@PostMapping("code")
public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
return userService.sendCode(phone, session);
}
根据请求中的 url :/user/code 定位到 UserController 中的 sendCode(…)
推测:点击 “登录” 会跳转到 login() 对应的页面中
② 点击 “登录” 在vscode上打开前端的代码,搜索 “请先确认阅读用户协议!”
定位到前端的 login.html
methods: {
login(){
if(!this.radio){
this.$message.error("请先确认阅读用户协议!");
return
}
...
③ 点击 “阅读协议” 前端弹出"手机号和验证码不能为空"
问题:前端是怎么获得用户输入的呢?
const app = new Vue({
el: "#app",
data: {
radio: "",
disabled: false, // 发送短信按钮
codeBtnMsg: "发送验证码", // 发送短信按钮提示
form:{
}
},
methods: {
login(){
.....
if(!this.form.phone || !this.form.code){
this.$message.error("手机号和验证码不能为空!");
return
}
axios.post("/user/login", this.form)
....
},
可以看到提示 “手机号和验证码不能为空!”,是在执行 if(!this.form.phone || !this.form.code) 之后,也即是从 from 中获取的 phone 和 code
④ from 怎么保存的 phone 和 code ? 而之后执行了 axios.post("/user/login", this.form) ,会将 from 作为请求参数
发送 login 请求时,将 form{phoone,code} 作为负载携带到请求里面 再回头看 UserController.java 中 login(…) 的逻辑,将 from 中的内容通过 @Requsetbody 读取到 LoginFromDTO 对象中
@PostMapping("/login")
public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
return userService.login(loginForm, session);
}
@Data
public class LoginFormDTO {
private String phone;
private String code;
}
到此为止:可以就 登录手机号与userMap 是怎么关联的给出答案
① 前端页面填写 phone,code 时会把信息保存到 from 表单,后者在 axios.post异步请求时将 phone 字段附加到 url 中
② 后端的 Controller 层,将请求分发给 login(…) 利用 @RequestBody获取 form{phone,code}
③ login(…) 登录流程中,通过loginFromDTO 获取 phone,通过 phone 查询数据库中的 user
④ 将 user 转化为存储在 redis 中的 userMap
3. 校验验证码之后呢?
① 验证码—登录凭证 在之前的处理流程中,登录成功后会跳转到首页。该流程仅仅通过校验手机号是否一致,判断能否跳转,并没有关于用户身份识别处理。 返回给前端 token 的逻辑是:
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
String token = UUID.randomUUID().toString(true);
return Result.ok(token);
}
② 多出来的 authorization 字段
上述:修改前端代码,禁止页面跳转,主要是为了验证,验证码一致就能登录 这时,再点击其他页面也是可以的【包括输入相同手机号进入到已注册用户的界面】,只是多了字段 authorization 点击其他页面的时候,请求头里面多了 authorization 字段,该字段的内容正是后端传给前端的 token
搜索 authorization 定位到 前端 的 commo.js 文件 测试其他页面,发现都已经携带了 authorization 字段,【/user/code 界面,以及 user/login 界面是没有的,因为还没有请求 token】
4. authorizaion 与 拦截器
通过上述分析已经知道:用户登录后,用户跳转任意页面时发送的HTTP请求头中都会携带 authorization 字段,而 authorization 字段里面的值正是保存在 redis 的hash 结构中,用于获取userMap 的key
通过 authorization --> token --> userMap 的处理逻辑是写在 拦截器中
RefreshTokenInterceptor.java
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
if (userMap.isEmpty()) {
return true;
}
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO);
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
写在拦截器中的目的:每个页面都需要获取用户进行相关操作,而拦截器可以在请求分发前先执行
上面逻辑处理很重要的一点是:将获取到的用户信息保存在了 threadLocal 中,这也是JVM线程区分用户的关键
MvcConfig.java
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
RefreshTokenInterceptor 会拦截所有请求路径,然后执行上面,获取token,根据 token 从 redis 中查询用户,然后将用户信息保存到 threadLocal 中
5. threadLocal 问题的关键所在
上面已经分析到:跳转不同的页面,都会先根据 authorization 获取 token,进而将 userMap 保存到 threadLocal ,则之后的处理逻辑都可以通过 threadLocal 获取 userMap 中的信息,即获取 user_id
下面以秒杀抢购为例验证:
@Override
public Result seckillVoucher(Long voucherId) {
Long userId = UserHolder.getUser().getId();
long orderId = redisIdWorker.nextId("order");
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(),
voucherId.toString(),
userId.toString(),
String.valueOf(orderId)
);
int r = result.intValue();
if(r != 0){
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
return Result.ok(orderId);
}
可以看到:执行秒杀逻辑前,是先从 userHolder 中获取 userId
而 UserHolder 是借助 ThreadLocal 属性将 UserDTO 保存到 ThreadLocalMap 中
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user){
tl.set(user);
}
public static UserDTO getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
6. ThreadLocal 与 ThreadLocalMap
6.1 使用 ThreadLocal 的原因
问题:为什么要把 userDTO 保存到 ThreadLocalMap 中,直接根据token 获取到的去 redis 中查不就行了吗?
分析:通过 token 查询 redis 中的 userDTO 是写在 拦截器中的。 写在拦截器中的好处是:查询 userDTO 的逻辑可以只写一遍,每个请求执行前先经过拦截器去查询即可
写在拦截器中的缺点是:查询 userDTO 的逻辑需要与请求解耦,任何请求来都可以查询,所以就需要使用一种结构,可以将请求的线程和查询的结果绑定。而这就是使用 ThreadLocal 的原因
6.2 ThreadLocal 与 Thread.currentThread()
问题:ThreadLocal 是如何将用户线程与请求结果进行绑定的?
① 首先以一个测试用例来分析 Threadlocal 原理
public class ThreadLocalTest02 {
public static void main(String[] args) {
ThreadLocal<String> local = new ThreadLocal<>();
IntStream.range(0, 10).forEach(i -> new Thread(() -> {
local.set(Thread.currentThread().getName() + ":" + i);
System.out.println("线程:" + Thread.currentThread().getName() + ",local:" + local.get());
}).start());
}
}
代码逻辑:
- main 主线程中创建 ThreadLocal 对象
- main 主线程中开启 10 个子线程
- 使用 local.set(…) 保存 Thread-i:i
- 获取当前线程 与 local 中保存的结果
运行结果:
线程:Thread-0,local: Thread-0:0
线程:Thread-3,local: Thread-3:3
线程:Thread-1,local: Thread-1:1
线程:Thread-2,local: Thread-2:2
线程:Thread-5,local: Thread-5:5
线程:Thread-4,local: Thread-4:4
线程:Thread-6,local: Thread-6:6
线程:Thread-7,local: Thread-7:7
线程:Thread-8,local: Thread-8:8
线程:Thread-9,local: Thread-9:9
上述结果一个很重要的特点是:获取的 Thread 线程,与从 threadLocal 中取到的线程是一致的
这是为什么?从 local.get() 入手分析:
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
- 先通过 Thread.currentThread() 获取当前线程
- 根据 当先线程获取 ThreadLocalMap 对象
- 从 threadLocalMap 中获取 value
问题:map.getEntry(this) 这里的this 指的是什么?
this 指调用该方法的对象,local.get() ,这里的 this 指的是 local 对象
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
可以看到 Entry 中确实存的key 是 ThreadLocal 类型的
问题:既然分析到 ThreadLoalMap 的 Entry 中存储的key 是 threadlocal,那对于本例来说,10个子线程的 key 不都是 local吗?为什么没有出现 不同线程由于key 相同,value 被覆盖的情况呢?
6.3 ThreadLocal && ThredLocalMap && threadLocals
为了回答上面 key 相同,value 没有被覆盖的问题,需要从 local.set(…) 入手
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
当前线程获取 ThreadLocalMap 是通过 getMap(t)
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
返回了 t.threadLocals,注意这里居然是返回了线程的属性
ThreadLocal.ThreadLocalMap threadLocals = null;
看到这里,ThreadLocal,ThreadLocalMap,threadLocals 三者的关系一目了然
- ThreadLocalMap 是 ThreadLocal 的静态内部类
- threadLocals 是Thread中的属性,是ThreadLocal.ThreadLocalMap 类型的
其实到这里,也就解释了为什么,key = local 的时候,10个子线程不会出现value 覆盖的情况
- <key. value> 是以Entry 的形式保存在 ThreadLocalMap 中的
- 每次添加和获取元素前都要获取 map,此处的map 是 Thread 中的 threadlocals 属性,是属于线程的
即:开启了10个子线程,创建了 1 个 ThreadLocal 对象作为key,但线程的值是保存在线程中的 ThreadLocal.ThreadLocalMap 类型的属性 threadLocals 中的
6.4 为什么不直接用 ThreadLocalMap
上面已经分析到:数据实际上是存储在 ThreadLocalMap 中的,那为什么不直接用 ThreadLocalMap ,而需要借助 ThreadLocal 呢?
考虑将 Thread 中的 threadLocals 改写下会怎样
ThreadLocalMap threadLocals = null;
- Thread 类中有 ThreadLocalMap 类型的属性 threadLocals 用于保存线程独立变量,key如何设计?
- threadLocals 是默认权限的,该属性可以被同一包下的类访问
即使下面一条将 default 改为 私有权限的,由于Thread 无法做到将类修饰为 final 类型的,子类完全可以放宽 threadLocals 的访问权限,这就造成了 threadLocals 不可能做到像 局部变量一样的线程安全的了
还有,不用 ThreadLoal 如何设计 key 呢?
使用 ThreadLocalMap 目的是为了保存线程变量,使该线程私有?则必然有通过线程获取value 的需求,那么key 就必然与 Thread.currentThread() 有关,这就回到了当不止有一个value 的时候,key又都需要与 Thread.currentThread() 有关,如何确保不会因为 key 一致,导致 value 被覆盖的问题上
下面看看使用 ThreadLocal 的好处
- 由于 ThreadLocalMap 是 ThreadLocal 中的静态内部类,所以必须要通过 ThreadLocal 访问
- 由于是 Default 访问权限的,意味着在 java.lang 包外,无法通过 thread 对象获取 threadLocals
- ThreaLocal 作为key,当获取线程变量的时候,都会先获取到 Thread.currentThread(),进而才能获取到 threadlocals
总结来说: ThreadLocal 既可以保证线程拥有私有变量不受其他线程影响,又可以解决线程和线程变量绑定问题
回到最初的问题:JVM 是如何识别用户的? jvm 线程根据 token 获取到 redis 中的 user 信息后,将其保存在自己的 threadlocals 中,响应客户端请求时都会先从 threadlocals中取值
每个线程中的 threadlocals 是互不影响的,只能由对应的线程获取其 threadlocals 中保存的用户信息
|