Laravel Session 状态检查导致持续刷新的解决方案
2025-03-11 17:04:16
Laravel 应用中 Session 状态检查导致 Session 持续刷新的问题及解决方案
在开发 Laravel/Inertia/Vue 应用时,咱们有时需要检查用户的 Session 是否过期,过期了就弹个提示框。 通常,这会通过前端定时向后端发送请求来完成。但在使用数据库作为 Session 驱动时,可能会遇到一个问题:仅仅是检查 Session 状态,却导致 Session 不断被刷新,无法真正过期。
问题出在哪儿了呢?
根本原因在于,Laravel 默认会在每次请求时更新 Session 的 last_activity
字段。即使我们只是想单纯地“读取” Session 状态,这个更新操作也会被触发,导致 Session “永不过期”。
原本的代码片段,如下:
后端 (Laravel):
Route::get('/session-status', function (\Illuminate\Http\Request $request) {
$sessionId = cookie(config('session.cookie'));
if (!\Illuminate\Support\Facades\Auth::check()) {
return response()->json(['session_expired' => true], 401);
}
// Fetch session directly from the database (without updating last_activity)
$sessionId = $request->session()->getId();
$session = \Illuminate\Support\Facades\DB::table('sessions')
->select('last_activity')
->where('id', $sessionId)
->first();
if ($session) {
$sessionLifetime = config('session.lifetime') * 60;
$sessionAge = now()->timestamp - $session->last_activity;
if ($sessionAge > $sessionLifetime) {
return response()->json(['session_expired' => true], 401);
}
}
return response()->json(['session_expired' => false]);
})->name('session.status');
前端 (Vue/Axios):
const showPopup = ref(false);
const checkSession = async () => {
try {
await axios.get('/api/session-status');
} catch (error) {
if (error.response && error.response.status === 401) {
showPopup.value = true;
}
}
};
// 每分钟检查一次 Session 状态
onMounted(() => {
setInterval(checkSession, 6000);
});
代码看起来没毛病,问题在于,$request->session()->getId()
这行代码在获取 Session ID 的同时,已经触发了 Session 的更新。
怎么解决这个问题?
下面提供几种解决方案,可以根据自己的实际情况选择。
解决方案一:使用只读的数据库连接
这种方法的核心思想是,创建一个专门用于读取 Session 数据的数据库连接,这个连接不参与任何写操作。
-
配置数据库连接(
config/database.php
):'connections' => [ 'mysql' => [ // ... 你的主数据库连接配置 ... ], 'mysql_read' => [ 'driver' => 'mysql', 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '3306'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), // ... 其他配置 ... //只读 'read' => [ 'host' => [env('DB_HOST', '127.0.0.1')], ], 'sticky' => true, //必须为 true ], // ... 其他数据库连接配置 ... ],
要点是
sticky
设置为true
,和read
。 这确保了对这个数据库连接的读取操作都指向同一个数据库实例(避免主从延迟)。 -
修改后端代码:
Route::get('/session-status', function (\Illuminate\Http\Request $request) { // $sessionId = cookie(config('session.cookie')); //不再需要 if (!\Illuminate\Support\Facades\Auth::check()) { return response()->json(['session_expired' => true], 401); } // 从cookie 获取 Session ID. $sessionId = $request->cookie(config('session.cookie')); if(!$sessionId) { return response()->json(['session_expired' => true], 401); } // 使用只读连接查询 Session 数据 $session = \Illuminate\Support\Facades\DB::connection('mysql_read') ->table('sessions') ->select('last_activity') ->where('id', $sessionId) ->first(); if ($session) { $sessionLifetime = config('session.lifetime') * 60; $sessionAge = now()->timestamp - $session->last_activity; if ($sessionAge > $sessionLifetime) { return response()->json(['session_expired' => true], 401); } } return response()->json(['session_expired' => false]); })->name('session.status');
这里使用了
DB::connection('mysql_read')
来获取只读的数据库连接。并且,使用$request->cookie()
来直接获得session id
.
原理: 通过只读的数据库连接查询 Session 数据,避免了 Laravel 框架自动更新 Session 的 last_activity
字段。
解决方案二:自定义 Session 中间件
我们可以创建一个中间件,专门用于处理 Session 状态检查请求,在这个中间件中,阻止 Session 的更新。
-
创建中间件:
php artisan make:middleware CheckSessionStatus
-
编写中间件逻辑 (
app/Http/Middleware/CheckSessionStatus.php
):<?php namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; class CheckSessionStatus { public function handle(Request $request, Closure $next) { // 如果是检查 Session 状态的请求,则不更新 Session if ($request->routeIs('session.status')) { //不做任何和 session 相关操作. } else{ //不是检查session 直接跳过, 进行默认的 session 操作. $request->session(); } return $next($request); } }
-
注册中间件(
app/Http/Kernel.php
):
在$middlewarePriority
数组内,把StartSession
提到最高优先级protected $middlewarePriority = [ \Illuminate\Session\Middleware\StartSession::class, \Illuminate\View\Middleware\ShareErrorsFromSession::class, \App\Http\Middleware\Authenticate::class, // ...其他 ];
然后把新建的
CheckSessionStatus
插入在\Illuminate\Session\Middleware\StartSession::class
之前。protected $middlewarePriority = [ \App\Http\Middleware\CheckSessionStatus::class, //新加的 \Illuminate\Session\Middleware\StartSession::class, // ...其他 ];
-
后端调整:
Route::get('/session-status', function (\Illuminate\Http\Request $request) { if (!\Illuminate\Support\Facades\Auth::check()) { return response()->json(['session_expired' => true], 401); } // 从cookie 获取 Session ID. $sessionId = $request->cookie(config('session.cookie')); if(!$sessionId) { return response()->json(['session_expired' => true], 401); } $session = \Illuminate\Support\Facades\DB::table('sessions') ->select('last_activity') ->where('id', $sessionId) ->first(); if ($session) { $sessionLifetime = config('session.lifetime') * 60; $sessionAge = now()->timestamp - $session->last_activity; if ($sessionAge > $sessionLifetime) { return response()->json(['session_expired' => true], 401); } } return response()->json(['session_expired' => false]); })->name('session.status');
要点,是从 Cookie 中获取,不走
session()
原理: 中间件会在 Session 启动 之前 执行。如果在 CheckSessionStatus
中间件里针对特定的路由(这里是 session.status
)不做和 session 相关的任何操作,那么框架就不会启动或者说接触 session。避免 Session 的更新操作。
解决方案三: 使用 Cache
替代 Session
这种方案是,将“是否需要显示弹窗”这个状态存储在缓存中(比如 Redis),而不是依赖 Session。
-
后端代码:
use Illuminate\Support\Facades\Cache; Route::get('/session-status', function (\Illuminate\Http\Request $request) { $userId = \Illuminate\Support\Facades\Auth::id(); //获得用户id if (!$userId) { return response()->json(['session_expired' => true], 401); } $cacheKey = 'user:' . $userId . ':session_expired'; //尝试从获得上次的记录 $sessionExpired= Cache::get($cacheKey); if($sessionExpired === true) { return response()->json(['session_expired' => true], 401); } //正常业务逻辑... return response()->json(['session_expired' => false]); })->name('session.status'); //在登出时候 Route::post('/logout', function (\Illuminate\Http\Request $request) { $userId = \Illuminate\Support\Facades\Auth::id(); $cacheKey = 'user:' . $userId . ':session_expired'; //设置过期标志 Cache::put($cacheKey, true, config('session.lifetime') * 60); //和 session 超时时间一致 \Illuminate\Support\Facades\Auth::logout(); //原有逻辑 // ... })->name('logout');
前端的调用不变,依然用401 来判断
-
额外说明:
user:{user_id}:session_expired
作为缓存键,确保每个用户有独立的标记。- 在用户登出时,将这个标记设置为
true
,并设置过期时间,与 Session 的过期时间保持一致。 - 在
/session-status
接口,只需要检查这个缓存值即可。 - 考虑使用 Redis 等高性能缓存服务。
原理: 使用缓存来管理“是否需要显示弹窗”的状态,与 Session 解耦,完全避免了 Session 更新的问题。
安全建议
无论你选择上述的哪种解决方案,一定保证:
- 不要 在
/session-status
接口中执行任何可能修改用户状态或数据的操作, 这个接口只是用作只读查询. - 如果可以,把
/session-status
的请求频率调低,避免对服务器造成不必要的压力。
总的来说,解决问题的核心思路都是不让session
的读操作产生副作用(写 last_activity
). 上述三种方式分别通过技术选型的调整,框架中间件, 缓存服务,避免接触session
组件,或者将逻辑处理分离,达到了这个目标。