在基于Token的认证授权体系中,「自动续期」是平衡系统安全性与用户体验的关键环节------既需避免Token长期有效带来的安全风险,又要防止用户操作中因Token过期被强制登出。本文聚焦前端定时刷新、滑动窗口、双Token(accessToken & refreshToken)三大主流续期方案,深入剖析其实现逻辑、优缺点,并结合生产环境实践,总结当前行业主流的技术选型与落地策略。
一、核心方案解析
Token自动续期的核心目标是:在不影响用户操作的前提下,动态延长Token的有效周期,同时保障认证链路的安全性。以下是三大方案的详细拆解:
方案一:前端定时刷新
1.1 实现原理
该方案的核心是「前端主动触发续期」:前端在获取Token时,记录Token的过期时间,通过定时器(如JavaScript的setInterval)设定一个略短于Token过期时间的周期,定期向后端发送续期请求,后端验证当前Token有效性后,返回新的有效Token,前端替换本地旧Token完成续期。
关键逻辑:
-
登录成功后,后端返回Token及过期时间(如2小时);
-
前端设定定时任务(如1小时50分钟执行一次);
-
定时任务触发时,携带当前Token请求后端续期接口;
-
后端验证Token有效,生成新Token返回,前端更新本地Token及过期时间;
-
若续期失败(如Token已失效),前端跳转登录页。
前端核心代码示例(JavaScript):
javascript
// 登录成功后存储Token和过期时间
function loginSuccess(token, expireMin) {
localStorage.setItem('token', token);
localStorage.setItem('expireTime', Date.now() + expireMin * 60 * 1000);
// 启动定时续期(提前10分钟续期)
startRenewTimer(expireMin - 10);
}
// 定时续期函数
function startRenewTimer(renewMin) {
// 清除已有定时器,避免重复
if (window.renewTimer) clearInterval(window.renewTimer);
// 定时触发续期
window.renewTimer = setInterval(() => {
const token = localStorage.getItem('token');
if (!token) {
clearInterval(window.renewTimer);
return;
}
// 发起续期请求
axios.post('/api/token/renew', { token })
.then(res => {
const newToken = res.data.data;
localStorage.setItem('token', newToken);
localStorage.setItem('expireTime', Date.now() + 120 * 60 * 1000); // 重置过期时间(2小时)
})
.catch(err => {
clearInterval(window.renewTimer);
// 续期失败,跳转登录
window.location.href = '/login';
});
}, renewMin * 60 * 1000);
}
1.2 优缺点分析
-
优点:
-
实现简单:前端逻辑轻量,后端续期接口开发成本低;
-
兼容性好:不依赖复杂的后端状态管理,适用于单体/分布式简单场景;
-
可预测性强:续期时机固定,便于问题排查。
-
-
缺点:
-
时间偏差风险:前端定时器受页面阻塞、系统时间偏差影响,可能出现"续期未触发但Token已过期";
-
无效请求浪费:用户离线/长时间无操作时,定时续期请求仍会触发,占用网络和服务器资源;
-
安全性隐患:若定时器周期设置不合理(如过晚),可能出现Token过期后续期请求被拦截的情况。
-
方案二:滑动窗口续期
2.1 实现原理
该方案的核心是「后端触发续期」:基于用户的有效操作动态续期,即当用户发起业务请求时,后端检查当前Token的剩余有效期,若剩余时间小于设定阈值(如30分钟),则在返回业务结果的同时,生成新的Token并通过响应头/响应体返回给前端,前端被动更新本地Token。因续期时机跟随用户操作滑动,故称"滑动窗口"。
关键逻辑:
-
Token初始过期时间设为固定值(如2小时);
-
用户发起业务请求时,后端拦截器解析Token,计算剩余有效期;
-
若剩余有效期 < 阈值(如30分钟),则生成新Token,与业务响应一同返回;
-
前端接收响应后,检测到新Token则更新本地存储;
-
若用户长时间无操作(超过2小时),Token自然过期,下次请求时跳转登录页。
后端核心代码示例(Java拦截器):
java
@Component
public class SlidingWindowRenewInterceptor implements HandlerInterceptor {
@Resource
private TokenUtil tokenUtil;
// 续期阈值:30分钟(单位:毫秒)
private static final long RENEW_THRESHOLD = 30 * 60 * 1000;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("Authorization").substring(7);
// 解析Token,获取过期时间
Claims claims = tokenUtil.parseToken(token);
long expireTime = claims.getExpiration().getTime();
long remainTime = expireTime - System.currentTimeMillis();
// 剩余时间小于阈值,触发续期
if (remainTime < RENEW_THRESHOLD) {
Long userId = claims.get("userId", Long.class);
String username = claims.get("username", String.class);
// 生成新Token
String newToken = tokenUtil.generateToken(userId, username, 2 * 60 * 60 * 1000);
// 新Token通过响应头返回
response.setHeader("New-Token", newToken);
}
return true;
}
}
前端核心代码示例(响应拦截器):
javascript
axios.interceptors.response.use(
response => {
// 检测响应头中的新Token,更新本地存储
const newToken = response.headers['new-token'];
if (newToken) {
localStorage.setItem('token', newToken);
}
return response;
},
error => {
if (error.response?.data?.code === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
2.2 优缺点分析
-
优点:
-
资源利用率高:仅在用户有操作时触发续期,无无效请求;
-
用户体验好:续期跟随业务请求完成,完全无感;
-
安全性更优:Token有效期与用户活跃周期绑定,闲置Token会自然过期。
-
-
缺点:
-
分布式环境复杂:多节点部署时,需保证Token状态同步(如依赖Redis存储Token信息);
-
后端侵入性强:需在拦截器中嵌入续期逻辑,对原有认证链路有改造成本;
-
极端场景体验差:用户在Token过期前最后一刻发起请求,可能因Token失效导致请求失败。
-
方案三:双Token(accessToken & refreshToken)机制
3.1 实现原理
该方案的核心是「双Token分工协作」:通过拆分"业务访问"与"续期授权"两个核心能力,引入两种功能隔离、有效期差异化的Token,形成"短期访问+长期续期"的安全认证体系,从根源上平衡安全性与用户体验。具体分工与设计逻辑如下------
-
accessToken(访问Token):核心职责是作为业务接口的访问凭证,设计为短期有效(推荐30分钟以内)。该Token需携带用户基础身份信息(如userId、角色标识),便于后端快速校验权限;因有效期短,即使被恶意窃取,攻击窗口也极小,安全性可控。
-
refreshToken(刷新Token):核心职责是为过期的accessToken提供续期授权,设计为长期有效(推荐7~30天,根据业务安全性要求调整)。该Token不参与业务接口校验,仅在续期接口中使用;后端需将refreshToken与用户身份、设备信息(可选)绑定存储,确保续期请求的合法性。
双Token机制的完整交互流程:
-
登录认证:用户输入账号密码登录,后端校验通过后,生成accessToken(30分钟有效期)和refreshToken(7天有效期);同时将refreshToken与用户ID、设备指纹(如浏览器UA、设备MAC)关联存储至Redis,并设置与refreshToken一致的过期时间。
-
业务访问:前端将accessToken放入HTTP请求头(如Authorization: Bearer {accessToken}),调用业务接口;后端拦截器解析Token,校验有效性(签名、过期时间、权限),通过则放行,否则返回401错误。
-
续期触发:前端通过两种方式感知accessToken状态------① 响应头预警:后端检测到accessToken剩余有效期小于阈值(如5分钟),返回Token-Warning: expire-soon头;② 主动校验:前端定期(如每5分钟)校验本地accessToken剩余有效期。两种情况均触发续期流程,携带refreshToken请求续期接口。
-
续期校验与生成:后端续期接口执行三重校验------① refreshToken签名有效性;② Redis中存储的refreshToken与请求携带的一致;③ refreshToken绑定的设备信息与当前请求设备匹配;校验通过后,生成新的accessToken(重置30分钟有效期);若refreshToken剩余有效期小于阈值(如3天),同步生成新的refreshToken,更新Redis存储并返回给前端。
-
refreshToken自身续期:refreshToken的续期不单独触发独立请求,而是"依附于accessToken续期流程"的滚动续期------当用户触发accessToken续期时,后端同步检查refreshToken的剩余有效期,若满足续期条件则自动生成新的refreshToken,实现"无感续期";若用户长期无操作导致refreshToken过期,则无法再续期,需用户重新登录。
-
异常处理:① 续期时refreshToken过期/无效/设备不匹配:返回401,前端清除本地Token,跳转登录页;② 业务请求时accessToken已过期:前端直接发起续期请求,续期成功后重试原业务请求,实现无感恢复;③ 账号注销/密码修改:后端删除Redis中对应的refreshToken,使所有关联的accessToken和refreshToken失效。
3.1.1 refreshToken续期核心逻辑详解
refreshToken设计为长期有效,但并非永久有效(如7天有效期),其续期核心目标是:在用户持续活跃的场景下,避免因refreshToken过期导致用户重新登录,进一步提升体验;同时通过"滚动续期"控制refreshToken的有效周期,降低泄露风险。具体逻辑如下:
续期核心思路
refreshToken续期的核心思路是「依附式滚动续期」,本质是将refreshToken的续期行为与accessToken的续期流程深度绑定,不额外发起独立续期请求,通过"动态阈值判断+状态同步更新"实现"无感续期"与"风险可控"的平衡。其核心逻辑可拆解为以下4个关键维度,同时兼顾用户体验与系统安全性:
1. 核心定义:滚动续期的本质是"依附式续期" refreshToken不设置固定的续期时间点,也不允许前端主动发起独立的refreshToken续期请求,而是完全"寄生"于accessToken的续期流程------只有当用户操作触发accessToken续期(如accessToken即将过期、accessToken已过期但refreshToken有效)时,才同步检查refreshToken状态并决定是否续期。这种设计的核心是"减少无效请求",避免因独立续期请求占用网络与服务器资源。
2. 设计目标:双重平衡的核心诉求 续期核心思路的设计围绕两个核心目标展开: ① 体验平衡:在用户持续活跃场景下,通过自动续期延长refreshToken有效期,避免用户因refreshToken过期频繁重新登录; ② 安全平衡:通过"非永久续期"控制refreshToken的最大有效周期,即使Token泄露,攻击者可利用的时间窗口也被严格限制(如最长不超过7天),降低安全风险。
3. 关键逻辑要素:4个核心规则 ① 续期触发时机:仅在accessToken续期请求有效时触发(需先通过refreshToken签名校验、Redis一致性校验、设备合法性校验三重验证),杜绝恶意续期; ② 阈值驱动判断:设定"续期阈值"(如3天),仅当refreshToken剩余有效期小于该阈值时才触发续期,避免频繁更新Token导致的状态同步开销; ③ Token更新规则:续期时生成全新的refreshToken(携带与旧Token一致的用户ID、设备ID等核心信息),新Token有效期重新计算(如重置为7天),旧Token立即失效; ④ 状态强同步:新refreshToken生成后,必须同步更新Redis中的存储(覆盖旧Token),确保分布式环境下所有节点均能识别新Token,避免状态不一致导致的续期失败。
4. 设计考量:为何不采用"独立续期"或"固定续期" 核心思路的设计规避了两种不合理方案,背后有明确的考量: - 规避"独立续期":若前端主动发起refreshToken续期请求,会增加无效请求(如用户离线时),且扩大攻击面(攻击者可能伪造续期请求); - 规避"固定续期":若按固定周期(如每天)续期,无法匹配用户实际活跃状态(活跃用户可能频繁续期,闲置用户无需续期),既浪费资源又无法精准控制风险。
续期触发条件
仅在以下场景触发refreshToken续期,无需独立发起请求,完全依附于现有交互流程:
-
用户主动操作触发accessToken续期(如accessToken剩余有效期<5分钟,前端发起续期请求);
-
accessToken已过期,但refreshToken仍有效(前端携带过期accessToken+有效refreshToken发起续期请求);
-
核心约束:必须通过设备合法性校验、refreshToken一致性校验后,才能触发续期,避免恶意续期。
续期实现流程(对应后端续期接口核心步骤)
java
@RestController
public class DoubleTokenRenewController {
@Resource
private TokenUtil tokenUtil;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private DeviceService deviceService;
// accessToken续期阈值:5分钟(单位:毫秒)
private static final long ACCESS_TOKEN_RENEW_THRESHOLD = 5 * 60 * 1000;
// refreshToken滚动续期阈值:3天(单位:毫秒)------核心参数:控制refreshToken续期时机
private static final long REFRESH_TOKEN_ROLL_THRESHOLD = 3 * 24 * 60 * 60 * 1000;
@PostMapping("/api/token/renew")
public ResultVO renewAccessToken(@RequestBody RenewRequest request, HttpServletRequest httpRequest) {
try {
// 1. 基础参数校验
String refreshToken = request.getRefreshToken();
String accessToken = request.getAccessToken();
if (StringUtils.isEmpty(refreshToken) || StringUtils.isEmpty(accessToken)) {
return ResultVO.error(400, "accessToken和refreshToken不能为空");
}
// 2. 解析refreshToken,获取用户核心信息(含设备ID,用于后续合法性校验)
Claims refreshClaims = tokenUtil.parseToken(refreshToken);
Long userId = refreshClaims.get("userId", Long.class);
String username = refreshClaims.get("username", String.class);
String storedDeviceId = refreshClaims.get("deviceId", String.class);
if (userId == null || StringUtils.isEmpty(storedDeviceId)) {
return ResultVO.error(401, "refreshToken格式非法");
}
// 3. 设备合法性校验(核心安全防护:防止refreshToken泄露后跨设备使用,保障续期安全)
String currentDeviceId = deviceService.getDeviceId(httpRequest); // 从请求头/UA生成设备唯一标识
if (!storedDeviceId.equals(currentDeviceId)) {
log.warn("refreshToken设备不匹配,userId:{},存储设备:{},当前设备:{}", userId, storedDeviceId, currentDeviceId);
return ResultVO.error(401, "当前设备未授权,需重新登录");
}
// 4. 校验refreshToken有效性(Redis一致性校验:支持主动失效,避免旧Token续期)
String redisKey = "token:refresh:" + userId;
String cachedRefreshToken = stringRedisTemplate.opsForValue().get(redisKey);
if (cachedRefreshToken == null || !cachedRefreshToken.equals(refreshToken)) {
return ResultVO.error(401, "refreshToken无效或已过期,需重新登录");
}
// 5. 校验原accessToken状态(避免用无效accessToken恶意续期,非必须但建议增加)
try {
Claims accessClaims = tokenUtil.parseToken(accessToken);
if (!userId.equals(accessClaims.get("userId", Long.class))) {
return ResultVO.error(401, "accessToken与refreshToken用户不匹配");
}
long accessRemainTime = accessClaims.getExpiration().getTime() - System.currentTimeMillis();
if (accessRemainTime > ACCESS_TOKEN_RENEW_THRESHOLD) {
return ResultVO.error(400, "accessToken未进入续期窗口,剩余有效时间:" + (accessRemainTime/60000) + "分钟");
}
} catch (Exception e) {
// accessToken已过期,不影响续期(核心:允许用有效refreshToken为过期accessToken续期)
log.info("accessToken已过期,触发强制续期,userId:{}", userId);
}
// 6. 生成新的accessToken(30分钟有效期,完成accessToken续期核心目标)
String newAccessToken = tokenUtil.generateAccessToken(userId, username, currentDeviceId);
// 7. refreshToken滚动续期(核心:refreshToken自身续期的核心逻辑)
long refreshRemainTime = refreshClaims.getExpiration().getTime() - System.currentTimeMillis();
String newRefreshToken = refreshToken;
if (refreshRemainTime < REFRESH_TOKEN_ROLL_THRESHOLD) {
// 满足续期条件:生成新的refreshToken,重置7天有效期
newRefreshToken = tokenUtil.generateRefreshToken(userId, username, currentDeviceId);
// 更新Redis中的refreshToken:覆盖旧值,确保后续续期请求仅识别新Token
stringRedisTemplate.opsForValue().set(redisKey, newRefreshToken, 7, TimeUnit.DAYS);
log.info("refreshToken滚动续期成功,userId:{},旧Token剩余有效期:{}天", userId, refreshRemainTime/(24*60*60*1000));
}
// 8. 返回结果:携带新accessToken、新refreshToken(如需)及更新标识
TokenRenewDTO result = new TokenRenewDTO();
result.setNewAccessToken(newAccessToken);
result.setNewRefreshToken(newRefreshToken);
result.setRefreshTokenUpdated(!newRefreshToken.equals(refreshToken)); // 标记refreshToken是否更新
return ResultVO.success("续期成功", result);
} catch (ExpiredJwtException e) {
// refreshToken自身已过期,无法续期,引导重新登录
log.error("refreshToken已过期,userId:{}", e.getClaims().get("userId", Long.class), e);
return ResultVO.error(401, "refreshToken已过期,需重新登录");
} catch (Exception e) {
log.error("accessToken续期失败", e);
return ResultVO.error(401, "续期失败:" + e.getMessage());
}
}
// 续期请求参数封装
@Data
public static class RenewRequest {
private String accessToken;
private String refreshToken;
}
// 续期响应参数封装:核心是"refreshTokenUpdated"标识,便于前端判断是否更新本地refreshToken
@Data
public static class TokenRenewDTO {
private String newAccessToken;
private String newRefreshToken;
private boolean isRefreshTokenUpdated;
}
}
// 补充:TokenUtil中生成Token的方法增强(加入设备ID,保障refreshToken与设备绑定)
class TokenUtil {
// 生成accessToken(加入设备ID)
public String generateAccessToken(Long userId, String username, String deviceId) {
Key key = Keys.hmacShaKeyFor(tokenConfig.getSecretKey().getBytes());
return Jwts.builder()
.claim("userId", userId)
.claim("username", username)
.claim("deviceId", deviceId) // 绑定设备ID
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 30 * 60 * 1000)) // 30分钟
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
// 生成refreshToken(加入设备ID,与accessToken绑定同一设备)
public String generateRefreshToken(Long userId, String username, String deviceId) {
Key key = Keys.hmacShaKeyFor(tokenConfig.getSecretKey().getBytes());
return Jwts.builder()
.claim("userId", userId)
.claim("username", username)
.claim("deviceId", deviceId) // 绑定设备ID,续期时校验
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + 7 * 24 * 60 * 60 * 1000)) // 7天
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
}
3.2 优缺点分析
-
优点:
-
续期体验无感:refreshToken续期依附于accessToken续期流程,无需用户额外操作,也不发起独立请求,资源利用率高;
-
风险可控:通过滚动续期避免refreshToken长期有效,即使泄露,攻击者可利用的周期也被限制(如最长不超过7天);
-
状态同步高效:续期后及时更新Redis中的refreshToken,确保分布式环境下所有节点都能识别新Token,避免状态不一致;
-
兼容性强:支持用户长期活跃场景下的持续登录,也支持短期闲置后仍能通过有效refreshToken续期,平衡体验与安全。
-
缺点:
-
逻辑复杂度提升:需额外处理refreshToken的剩余有效期判断、新Token生成、Redis更新及前端同步等逻辑,开发维护成本增加;
-
分布式一致性依赖:若Redis集群故障,refreshToken续期后无法同步状态,可能导致部分节点识别旧Token失效,需配套缓存降级方案;
-
旧Token失效延迟:refreshToken续期后,旧Token在Redis中被覆盖,理论上立即失效,但极端情况下若存在旧Token的缓存未清理,可能出现短暂的重复续期风险(需通过设备绑定、请求频率限制弥补)。
二、生产环境主流技术选型
结合行业实践,生产环境的技术选型需综合考虑「安全性、用户体验、系统复杂度」三大因素,不同场景的主流方案如下:
2.1 主流选型:双Token(accessToken + refreshToken)+ Redis
这是当前生产环境中最广泛使用的方案,尤其适用于中大型分布式系统(如电商、政务、企业级应用),核心技术栈搭配与落地要点如下:
-
安全性极高:accessToken短期有效,即使泄露,攻击者仅能在短时间内利用;refreshToken仅用于续期接口,攻击面窄,且可通过设备绑定、Redis校验实现精准管控;
-
优点:
-
扩展性强:支持多端登录(为不同设备分配独立refreshToken)、强制登出(删除Redis中指定用户的refreshToken)、异常设备拦截,完全适配分布式高并发场景;
-
用户体验佳:accessToken过期可通过refreshToken无感续期,无需重新输入账号密码;refreshToken支持滚动续期,进一步延长用户登录状态;
-
风险可控:可通过Redis对refreshToken进行实时管控,支持过期时间动态调整、异常续期行为监控,便于安全风险追溯。
选型原因:该方案在安全性和用户体验之间达到最佳平衡,可扩展性强,能应对多端、高并发、分布式等复杂场景,是大厂主流实践(如微信小程序、支付宝开放平台均采用类似机制)。
2.2 轻量化选型:滑动窗口续期
-
状态依赖强:核心依赖Redis等缓存中间件的高可用,若Redis集群故障,将导致续期功能失效,需配套缓存降级方案;
-
实现复杂度高:需维护两套Token的生成、校验、续期逻辑,涉及设备标识生成、Redis状态同步、多场景异常处理,后端开发与维护成本较高;
-
缺点:
-
refreshToken安全风险:若refreshToken被恶意窃取且绕过设备校验,攻击者可长期获取accessToken,需额外配套敏感操作二次验证、续期频率限制、异常行为告警等防护措施。
适用于小型单体应用、内部系统(如后台管理系统),核心技术栈:
-
Token生成:JWT/自定义Token;
-
续期触发:后端拦截器(无需缓存,无状态设计);
-
前端适配:响应拦截器被动更新Token。
选型原因:实现简单,无额外缓存依赖,开发维护成本低,能满足内部系统的基本需求(用户操作频繁,Token闲置过期概率低)。
2.3 极少用选型:前端定时刷新
仅适用于极简单的小型应用(如个人博客后台、工具类应用),核心场景:
-
状态存储:Redis Cluster(集群部署保障高可用,存储refreshToken、用户-设备关联关系、Token失效标记;推荐使用String类型,key设计为"token:refresh:{userId}:{deviceId}",便于多端登录管控);
-
Token生成:JWT(无状态,便于分布式节点快速解析;支持自定义载荷,可嵌入userId、设备ID等核心信息);推荐使用非对称加密(RSA)替换HS256对称加密,避免密钥泄露导致的Token伪造风险;
-
用户量少、操作频率低;
-
额外防护体系:① 设备标识:基于浏览器UA、IP、设备硬件信息生成唯一deviceId,与refreshToken绑定;② 频率限制:对续期接口设置限流(如单个deviceId每分钟最多续期1次),防止恶意请求;③ 异常监控:监控续期失败率、跨设备续期、高频续期等异常行为,触发告警;④ 缓存降级:Redis故障时,临时启用本地缓存兜底(仅保留核心用户续期功能,降低影响范围)。
-
前端适配:axios拦截器(监听Token-Warning响应头,主动发起续期请求;续期成功后,同步更新本地accessToken和refreshToken;支持续期失败后的重试机制,确保业务请求不中断);Token存储推荐使用HttpOnly Cookie(避免XSS攻击窃取Token),配合Secure属性(仅HTTPS传输)、SameSite属性(防止CSRF攻击);
-
无需分布式部署,系统架构简单;
-
对资源浪费不敏感。
选型原因:开发最快,但安全性和用户体验存在明显短板,不推荐用于生产环境的面向用户应用。
2.4 进阶优化:混合方案
部分复杂场景会结合「滑动窗口 + 双Token」优化,例如:
-
accessToken续期采用滑动窗口机制(跟随用户操作续期);
-
若用户长时间无操作导致accessToken过期,再通过refreshToken无感续期;
-
优势:进一步减少续期请求,提升用户体验,同时保障安全性。
三、总结
Token自动续期方案的选型需匹配业务场景:
-
中大型分布式应用:优先选择「双Token + Redis」,兼顾安全性、扩展性和用户体验;
-
小型单体/内部系统:选择「滑动窗口续期」,平衡开发效率和基本需求;
-
极简单应用:可临时使用「前端定时刷新」,但需注意风险控制。
生产实践中,无论选择哪种方案,都需配套完善的安全机制:Token加密传输(HTTPS)、密钥安全管理(配置中心加密存储)、异常监控(Token失效频率、续期失败告警)、主动失效机制(注销、密码修改、踢人下线),确保认证链路的安全性和稳定性。