直接上手JWT来保护API,这个话题我在多个项目里折腾过,从最初的照葫芦画瓢到后来踩坑踩到怀疑人生,再到总结出一套能落地到生产环境的方案,中间经历了不少反复。如果你现在正准备给前后端分离的项目加用户认证,或者被领导要求把现有的Session方案迁到Token方案,这篇文章应该能帮你少走很多弯路。
文章围绕“用户认证与授权:使用JWT保护你的API”这个核心,我会把JWT的原理、整体设计思路、实际落地步骤、常见问题排查以及安全加固策略全部串一遍。为了让你看得不晕,我会先把“为什么要用JWT”讲透,然后给出一套完整的实操流程,最后再分享那些只有实际写过才知道的坑。无论你用的是Node.js、Python还是Java,核心思路都是通用的,具体的代码示例我会用常见的框架风格来写,你可以直接对照迁移。
1. 项目整体设计与思路拆解
1.1 这个项目到底在解决什么问题
在前后端分离的架构下,API是后端暴露给前端、移动端甚至第三方服务的“门面”。门面如果不设防,谁都能往里闯,数据泄露只是时间问题。传统方案里,Session认证依赖服务器端存储会话状态,每次请求都要从存储里查会话,服务端多了会话管理压力,而且对多实例部署、水平扩展都不太友好。
JWT方案的核心思路是把认证信息本身发给客户端,客户端在后续请求中带上它,服务端通过签名验证来确认身份。它把“会话状态”从服务器内存里搬到了“客户端持有、服务端验证”的模型里,天然适合无状态API。用一句话来概括:JWT就是一张经过签名、有时效的通行证。
这张通行证解决了三件事:
- 身份是谁:通过Token里的用户ID和自定义声明识别用户。
- 有效期控制:Token自带过期时间,无需服务端集中管理过期状态。
- 权限控制:Token里可以携带角色、权限级别,后端在接口入口统一校验。
1.2 为什么选择JWT而不是传统Session
我在做一个“模拟项目X”的后端服务时,一开始用的还是传统的Session机制。当时服务是单实例部署,Session存在进程内存里,联调起来也非常顺手。但后来加了第二个实例,负载均衡一开,用户在A实例登录,请求被分到B实例,B实例内存里没有登录态,用户直接被弹回登录页,体验非常割裂。
这个时候我知道需要把会话数据外置,比如放到Redis,或者直接切换到JWT。对比了两种方案后,我选择JWT,主要理由有三个:
- 无状态,天然支持水平扩展:JWT验证只需要密钥,不需要查存储。新加实例零成本,不会成为系统的性能瓶颈。当然,如果后续需要做Token主动失效,还是要引入黑名单存储,那就得重新评估。
- 跨端与跨服务友好:移动端、小程序、Web端都只需要保存一个Token字符串。微服务之间也可以靠JWT传递用户身份,减少层层透传参数。Session在这种场景就需要做会话同步或集中存储,复杂度直接上升。
- 后端简化了不少逻辑:不需要Session序列化、反序列化、过期清理等操作,注册/登录接口只需要签发Token,接口保护只需要验证Token,整个认证链路变短了。
这不是说Session方案一无是处,它仍然适合传统服务端渲染的Web应用。但在纯API场景下,JWT的无状态优势确实更贴合需求。
1.3 这个项目的功能模块拆解
我做的这个模拟项目(还是叫“某跨平台系统”好了),最终敲定的功能模块分为四块:
- 用户注册登录:用户提供用户名和密码,后端校验通过后,签发两张Token——Access Token和Refresh Token。
- Access Token保护API:核心业务接口统一走JWT验证中间件,不合法、过期、签名不对的请求一律拦下。
- Refresh Token续期:Access Token过期后,客户端用Refresh Token换新的Access Token,避免用户频繁重新登录。
- 角色权限控制:在JWT的payload里塞入角色字段,某些管理接口额外校验角色,形成“先认证再授权”的链路。
这套方案做完后,整个系统的接口保护从“裸奔”变成了“分层设防”。一线开发时你可能会觉得“这不是理所当然吗”,但实际写代码的时候,我在两步之间漏掉了Token刷新逻辑,结果前端一到Token过期就被踢下线,后来才补上了刷新机制。这里提前告诉你:刷新Token和Access Token必须成对设计,少一个都会在生产环境出丑。
2. JWT核心原理与关键概念
2.1 JWT的结构拆解
JWT全称JSON Web Token,它不是一个加密协议,而是一个签名+编码的结构。它由三部分组成,用两个点分隔:
xxxxx.yyyyy.zzzzz第一部分是Header,一般长这样:
{ "alg": "HS256", "typ": "JWT" }alg表示签名算法,HS256表示HMAC-SHA256,typ固定为JWT。第二部分是Payload,里面装的就是用户相关的声明数据,比如:
{ "sub": "123456", "name": "某开发者", "role": "admin", "iat": 1710000000, "exp": 1710003600 }sub是用户ID,name是用户名,role是角色,iat是签发时间,exp是过期时间。第三部分是签名,Signature。签名到底是干什么的,我用一句大白话给你解释:前面Header和Payload是在明文上网,谁都能解码看内容,但谁都不能篡改内容,因为一旦改了,签名校验就会失败。
签名的生成公式是:
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret_key )服务端用同一个密钥重新计算签名,如果计算出来的签名和Token携带的签名不一致,说明Token被改过了,直接拒绝。
2.2 签名与加密到底有什么区别
我在给团队做分享时,经常被问到:“JWT不是加密了吗?为什么说它不安全?”这是一个很常见的误区。JWT的Payload默认只是Base64Url编码,不是加密。Base64Url解码成本极低,任何人拿到Token都能用工具解出里面的内容。
所以千万不要把密码、身份证号、手机号等敏感信息放在Payload里。JWT签名保证的是“内容不可篡改”,而不是“内容不可见”。如果非要让部分字段不可见,那就需要对整个Token做额外的加密处理,或者用JWE(JSON Web Encryption)这样的方案,而不仅仅是JWS签名。
我个人的经验是,在业务场景里,尽量把Payload里的信息降到最少,只放用户ID、角色、过期时间、Token类型。其他的用户详情,后端每次需要时再查数据库或缓存,而不是图方便一次性塞进Token里。
2.3 认证与授权的边界
认证和授权是两个环节,很多人混着用。认证解决的是“你是谁”的问题,授权解决的是“你能干什么”的问题。
在JWT方案里,整个流程是这样的:
- 用户提交用户名密码,服务端认证身份,签发出Token。
- 后续请求头带上
Authorization: Bearer <token>,服务端验证签名和过期时间,完成认证。 - 服务端在接口内部读取Token里的角色字段,判断是否允许执行操作,完成授权。
打个比方:保安查了你的工牌确认你是公司员工,这是认证;进了公司之后,只有项目经理能进资料室,这是授权。JWT里这两个环节都能覆盖,但大多数脚手架代码都只实现了认证,忽略了授权。如果你要保护的管理接口不校验角色,那普通用户直接手动调接口就能拿到管理数据,这个风险比Token过期还严重。
2.4 Access Token与Refresh Token的分工
很多刚接触JWT的同学经常问:为什么不给一个永久有效的Token多方便?如果Token永不过期,一旦泄露,别人就能永久冒用你的身份。所以短期有效的Access Token才是常态。一般Access Token的有效期设15分钟到2小时,Refresh Token的有效期设7天到30天。
Refresh Token的作用是“在Access Token过期后,免登录换取新的Access Token”。它的逻辑类似“自动续签”:你进公园买了一张2小时有效的门票,快到期时你到服务台出示“年卡会员凭证”,工作人员直接给你换一张新门票,不用重新排队买票。
这个设计的核心价值在于:短时效Access Token减少了泄露后的风险窗口,长时效Refresh Token保证了用户无需频繁重新登录。同时,Refresh Token需要更严格地保管,客户端一般放在本地存储里,且只在刷新接口传输。
3. 环境准备与项目搭建
3.1 技术选型依据
在“某跨平台系统”这个项目里,我选择的技术栈是Node.js + Express(认证中间件单独写了一个),数据库用的MongoDB,密码哈希用的bcrypt。这套选型在国内团队中很常见,也是社区资料覆盖面最广的组合之一。如果你用的是其他语言,逻辑完全一样,只是库的调用方式略有差异。
关键依赖只有三块:
- JWT库:负责生成和验证Token。
- 密码哈希库:负责把用户密码存储为不可逆的哈希值,而不是明文。
- 中间件机制:Express的中间件管道非常适合做认证拦截,Java的Filter/Interceptor同理。
3.2 环境与目录结构
项目结构我用的是按模块拆分的规则:
src/ config/ # 配置项:密钥、过期时间、端口 models/ # 数据模型:User、RefreshToken controllers/ # 业务处理:authController、userController middleware/ # AuthMiddleware、RoleMiddleware routes/ # 路由定义 utils/ # jwt工具封装中间件单独放在middleware目录,是我进入生产环境后养成的好习惯。很多初学项目把校验逻辑写在每个controller里,导致同样的代码复制粘贴几十遍,后来一改逻辑就要全局搜索替换,非常痛苦。
3.3 密钥管理要点
密钥是JWT方案中的命门。HS256算法是对称密钥,签发和验证用同一个secret,一旦泄露,任何人都能伪造Token。我在项目里做了这么几件事:
- secret不写在代码里,通过环境变量注入,本地开发用.env文件,生产环境用配置中心或部署平台的变量管理。
- secret的强度至少是32字节以上的随机字符串,不要用
jwt_secret_2024这种弱口令。 - 定期轮换密钥,线上环境通过配置平台热更新,切换时留出双密钥验证的过渡期,避免把所有用户Token全部打失效。
这些经验不是文档里现成的,是我踩了“密钥写死在代码仓库”的坑之后总结出来的。有个开发者的Token被伪造,起因很简单,代码被第三方拷过去分析,密钥就暴露了。
4. 实操过程与核心环节实现
4.1 用户注册与密码存储
用户注册是整个认证链路的地基。密码存储如果不规范,后面全白搭。我明确不建议在数据库里存明文密码,这样即使数据被拖库,也至少能保住用户的密码原文不被直接泄露。
密码哈希我用的bcrypt算法。它自带盐值(salt),每个用户同一密码算出来的哈希值都不同,彩虹表攻击基本失效。同时bcrypt可以调整计算成本(cost factor),把单次哈希耗时控制在合理范围,暴力破解的成本指数级上升。
Express风格的表单校验逻辑大致如下:
const bcrypt = require('bcrypt'); // 注册接口:校验用户名唯一 + 密码强度,然后创建用户 router.post('/register', async (req, res) => { const { username, password } = req.body; if (!username || !password || password.length < 8) { return res.status(400).json({ message: '用户名或密码不合法' }); } const existed = await UserModel.findOne({ username }); if (existed) { return res.status(409).json({ message: '用户名已存在' }); } const hashedPassword = await bcrypt.hash(password, 12); const user = await UserModel.create({ username, password: hashedPassword }); res.status(201).json({ id: user.id, username: user.username }); });bcrypt的cost设为12在普通服务器上的单次耗时大约在100毫秒到300毫秒之间。cost太低不安全,太高会影响在高并发时刻的注册/登录接口。
4.2 登录与Token签发
用户登录后,后端校验密码正确,然后签发两张Token。我这里先封装一个工具函数,便于多处复用:
const jwt = require('jsonwebtoken'); const ACCESS_SECRET = process.env.ACCESS_SECRET; const REFRESH_SECRET = process.env.REFRESH_SECRET; function signAccessToken(user) { return jwt.sign( { sub: user.id, username: user.username, role: user.role, type: 'access' }, ACCESS_SECRET, { expiresIn: '15m' } ); } function signRefreshToken(user) { return jwt.sign( { sub: user.id, type: 'refresh' }, REFRESH_SECRET, { expiresIn: '7d' } ); }登录接口的核心逻辑:
router.post('/login', async (req, res) => { const { username, password } = req.body; const user = await UserModel.findOne({ username }); const isMatch = user && (await bcrypt.compare(password, user.password)); if (!isMatch) { return res.status(401).json({ message: '用户名或密码错误' }); } const accessToken = signAccessToken(user); const refreshToken = signRefreshToken(user); // 将refreshToken哈希后入库,以便后续做“主动失效” await RefreshTokenModel.create({ userId: user.id, tokenHash: hashToken(refreshToken), expiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000) }); res.json({ accessToken, refreshToken }); });这里有一个关键细节:Access Token和Refresh Token不要使用同一个secret。如果使用同一个secret,Refresh Token的type声明就形同虚设,一旦secret泄露,所有Token都能伪造。用两个独立密钥,不同职责互相隔离。
4.3 请求验证中间件
在Express里,认证中间件放在需要保护的路由前。这个中间件做的事很清晰:取Header里的Bearer Token,验证签名,验证过期时间,把解析结果挂到request对象上供后续handler使用。
function authMiddleware(req, res, next) { const header = req.headers.authorization || ''; const token = header.startsWith('Bearer ') ? header.slice(7) : null; if (!token) { return res.status(401).json({ message: '未提供认证令牌' }); } try { const payload = jwt.verify(token, ACCESS_SECRET); if (payload.type !== 'access') { throw new Error('令牌类型错误'); } req.user = { id: payload.sub, username: payload.username, role: payload.role }; next(); } catch (err) { return res.status(401).json({ message: '认证令牌无效或已过期' }); } }这里最容易犯的错误是只用jwt.verify成功了就放行,没有校验type。如果你把Access和Refresh Token的secret分开了,这个方法能兜住大半锅。我再补一层type校验,是为了防止未来有人用Refresh Token调业务接口造成混淆。
4.4 授权中间件的实现
认证中间件管的是“你是谁”,授权中间件管的是“你够不够格”。我定义了一个工厂函数,传入允许的角色列表,返回一个中间件:
function requireRole(...allowedRoles) { return (req, res, next) => { if (!req.user) { return res.status(401).json({ message: '未认证' }); } if (!allowedRoles.includes(req.user.role)) { return res.status(403).json({ message: '无权限访问' }); } next(); }; } router.get('/admin/users', authMiddleware, requireRole('admin'), (req, res) => { // 只有 admin 能进 });403和401要分清。401是“你没登录或登录已失效”,需要前端跳登录页;403是“你已登录但没权限”,前端应该提示禁止访问而不是把用户踢下线。很多项目把403当成401处理,前端会陷入“登录了又让我登录”的循环。
4.5 Token刷新链路设计
Access Token过期后,前端不能直接把用户踢下线,而是走Refresh Token换新。刷新接口长这样:
router.post('/refresh', async (req, res) => { const { refreshToken } = req.body; if (!refreshToken) { return res.status(400).json({ message: '缺少刷新令牌' }); } try { const payload = jwt.verify(refreshToken, REFRESH_SECRET); if (payload.type !== 'refresh') throw new Error('类型错误'); const user = await UserModel.findById(payload.sub); if (!user) return res.status(401).json({ message: '用户不存在' }); const tokenHash = hashToken(refreshToken); const record = await RefreshTokenModel.findOne({ userId: user.id, tokenHash }); if (!record) return res.status(401).json({ message: '刷新令牌已失效' }); const newAccessToken = signAccessToken(user); res.json({ accessToken: newAccessToken }); } catch (err) { res.status(401).json({ message: '刷新令牌无效或已过期' }); } });Refresh Token入库是很多教程不会跟你讲到的细节。别小看这一步,如果Refresh Token不落地存储,“服务端主动注销某用户所有设备”这种需求根本无法实现。存TokenHash而不是明文,万一库里数据泄露,攻击者拿着哈希也无法直接离线调用刷新接口。
4.6 前端接入要点
后端把接口暴露出来,前端是个单页应用,用Axios做请求。我用三个要点帮助前端同学排掉了大部分联调坑:
- 请求拦截器统一添加Authorization头。
- 响应拦截器统一捕获401,此时调用刷新接口拿新的Access Token,再重放原请求。
- 本地存储建议放在内存变量或localStorage。放在localStorage的问题是如果被XSS攻击,Token能被脚本窃取;短期放在内存里刷新就没了,体验差。折中方案是Access Token放内存,Refresh Token放HttpOnly Cookie,但这会把纯Token模式复杂化。个人项目的经验是:如果你的站点做好了XSS防护,localStorage能接受;如果安全要求高,选Cookie方案别手软。
下面是一个常见的Axios拦截器示意:
api.interceptors.response.use( (response) => response, async (error) => { const { status } = error.response || {}; if (status !== 401 || error.config._retry) { return Promise.reject(error); } error.config._retry = true; const refreshToken = getRefreshToken(); if (!refreshToken) { redirectToLogin(); return Promise.reject(error); } const { accessToken } = await api.post('/auth/refresh', { refreshToken }); setAccessToken(accessToken); error.config.headers.Authorization = `Bearer ${accessToken}`; return api(error.config); } );这套逻辑有个隐藏的并发问题:当多个请求同时401,刷新接口会被触发多次。优化的暴力但有效做法是用一个Promise变量保存正在进行的刷新请求,后续401直接等待同一个刷新Promise完成,而不是重新发起。这是我在一次压测里发现的,刷新接口被打爆了,一次登录凭证换了几百个新Token回来。
4.7 生产环境部署时的必要调整
开发环境下,JWT的过期时间可以设长一点方便调试,但生产环境必须把这些参数变成配置项。我在“某跨平台系统”上线时,调整了这些参数:
| 参数 | 开发环境 | 生产环境 |
|---|---|---|
| Access Token有效期 | 1天 | 15分钟 |
| Refresh Token有效期 | 30天 | 7天 |
| 签名算法 | HS256 | HS256(后续可换RS256) |
| 密钥来源 | .env | 配置中心 |
| 刷新Token存储 | 内存字典 | MongoDB集合 |
换用RS256是未来值得考虑的方向。RS256是公私钥非对称签名:私钥负责签发,公钥负责验证。好处是签发端(认证服务)和验证端(业务API服务)可以分离,业务服务只需要持有公钥,不接触私钥,攻击面变小。缺点是密钥管理和JWT库配置更复杂。如果项目还没那么庞大,HS256量级完全够用。
5. 常见问题与排查技巧实录
5.1 401不是“有效但过期”那么简单
很多新手看到401就认定是Token过期,其实401可能来自四个原因:
| 原因 | 特征 | 解决方式 |
|---|---|---|
| 没带Token | Header里没有Authorization | 检查前端请求拦截器 |
| Token格式错误 | 没有Bearer前缀 | 检查Authorization头拼写 |
| Token签名失败 | 密钥不匹配或Token被篡改 | 检查服务端环境变量是否变更 |
| Token过期 | payload里的exp小于当前时间 | 走刷新流程换新Token |
排查时先去jwt.io手动解码,看看payload里的exp字段,再对比当前服务器时间。我第一次查线上401问题时,发现服务器比真实时间慢了10分钟,导致一些刚刚签发的Token被判过期,害得我查了一天代码逻辑,最后是运维解决的时间同步问题。
5.2 时钟偏移问题
JWT的过期验证依赖服务器本地时间。如果服务器的时间没有同步,就会出现两种离谱情况:一是Token刚签发就过期,二是Token过期了还能用一段时间。解除这个隐患的最根本做法是配置NTP时间同步。在代码层面,jwt库也提供了clockTolerance参数,允许验证时容忍一定的秒数偏差。
但千万别过度宽容。我见过有人把clockTolerance设成24小时,Access Token的15分钟有效期名存实亡。合理设置是30到60秒,结合NTP同步,足够覆盖正常误差。
5.3 Refresh Token的并发竞态
前面提到过,并发的请求同时401会触发多次刷新。如果不加控制,至少会造成无效请求变多,严重时刷新接口被限流,用户被强制下线。
我自己写了一个简单的单例刷新控制,思路就是用一个共享的Promise保存刷新任务:
let refreshPromise = null; function getNewAccessToken() { if (!refreshPromise) { refreshPromise = api .post('/auth/refresh', { refreshToken }) .then((res) => res.data.accessToken) .finally(() => { refreshPromise = null; }); } return refreshPromise; }这段代码的作用是把多次并发的刷新请求收敛成一次。等第一次刷新完成后,promise被置空,后续新的401仍能重新触发刷新流程。这个优化实测下来注册和登录的请求压力下降了不少。
5.4 黑名单与主动退出登录
JWT的一个缺陷是“在过期前无法真正作废”。用户修改密码后,其他人的已签发Token至少在过期前依然有效;用户在后台点了“退出所有设备”,服务端什么也做不了,除非引入黑名单。
我常用的方案是把需要作废的Token的jti(JWT ID)存到Redis,设置一个和Token剩余有效期一致的TTL。每个请求验证完签名后,再用jti查一下Redis,如果命中黑名单就拒绝。这套方案在正式项目里跑起来很稳定,代价是牺牲了一部分“无状态”特性——验证时多了一次Redis查询。不过换来了可控的主动失效能力,我认为是值得的。
5.5 安全加固中的关键注意项
在实际落地过程中,下面这几条是我最终定稿为“必须遵守”的安全基线:
- 密码永不明文存储,用bcrypt或argon2做哈希。
- 登录接口和刷新接口必须做限流,防止暴力破解和滥用。
- Access Token有效期不超过2小时。
- 不要把用户敏感信息写进Payload,JWT只能防篡改不能防窥探。
- 密钥必须独立管理,至少32字节随机字符串,定期轮换。
- 传输走HTTPS,否则Token明文容易被中间人截获。
- Refresh Token换新Access Token时,也可以顺便轮换Refresh Token,形成“Refresh Token轮换”机制,进一步降低泄露风险。
最后这条实践在实际项目中收益明显:每次刷新都换新的Refresh Token,旧Refresh Token立即失效。这样即使某个Refresh Token泄露,攻击者拿到的也只是一个已使用的临时凭证,有效期窗口急剧缩小。
6. 从JWT到更完整的认证体系
6.1 当前方案在实际项目中的定位
JWT是整个API安全体系里的重要一环,但还不是全部。只要你做的是面向公网的API,上面这套只保证“有Token才能进”,但“进的人是好人还是坏人”、“进来了能访问哪几条数据”这些更深层的问题,还需要进一步补强。
我会在JWT之上再叠加几层:
- API网关层:做统一流量入口,限流、防DDOS、WAF,在到业务API前把明显恶意流量挡掉。
- 数据权限层:同一Token的用户,按机构、角色、数据范围过滤。比如区域管理员只能访问自己辖区内订单,即使角色是user也不能跨越数据边界。
- 审计日志层:关键写操作记录用户ID、操作内容和来源IP,出了事情可追溯。
JWT只负责身份认证这个环节,上面的那些事情都不可省略。
6.2 什么时候应该考虑接入OAuth 2.0
如果你的系统不只是自己的前端在调用API,还要开放给第三方开发者,那就需要考虑OAuth 2.0协议了。OAuth 2.0和JWT并不是互斥的,OAuth中Access Token的具体格式完全可以是JWT。它们的关系是:OAuth解决的是授权协议框架,JWT解决的是Token的具体编码和签名方式。
模拟一个场景:你自己的App和内部服务用JWT就能满足;但要接入微信登录、开放API给合作方时,OAuth 2.0的授权码流程才是标准答案。我在做开放平台类项目时就用JWT作为下发给第三方的Access Token载体,签名验证逻辑几乎不用改,只是签发入口从登录接口变到了OAuth授权流程。
6.3 JWT与Session选型决策
做完这个项目,我对方案选型的体会更具体了:
- 如果API是机器调用的,第三方服务需要平移身份,JWT合适。
- 如果是纯内部工具或后台管理系统,服务端渲染为主,Session + Cookie也许更省心。
- 如果需要精准的会话控制,比如“监控用户所有登录设备数”、“管理员踢人”,Session集中存储方案可操作性更强,JWT要做黑名单实现类似效果,多一层存储成本。
最终选型不应该是“谁流行选谁”,而是“哪一个特性组合更贴合你的需求”。我这次因为是多实例、多端的API项目,JWT显然是正确选择,但这不代表它能适用于所有场景。
7. 写在最后的体会与建议
做完“某跨平台系统”的整套JWT认证授权,我最大的体会是:JWT本身不难,难的是把整个认证生命周期管好。密钥管理、Token过期策略、刷新流程的并发控制、被动登出方案,哪一环偷懒,生产环境就一定会以bug的形式还回来。
这里有一个细节值得单独提出来:不要把JWT的exp时间设成“永久”。即使对内网系统,时间约束都是安全基线。将来如果想调整过期时间策略,参数化设计能帮你少改很多硬编码。
如果你准备在新的项目里引入JWT,我建议从最简流程起步——注册、登录、鉴权中间件,三步跑通后再加刷新Token、角色权限、黑名单。别一上来就全上,复杂的逻辑在调试时会把问题混在一起,难以定位。我最初就是因为一上来就考虑刷新机制和角色权限,导致排查问题时忽东忽西,后来改成增量演进,效率和稳定性明显提升了。