1. 从一次登录卡顿说起:为什么加密优化不是可选项
去年冬天,一个做安防集成的老哥找我吐槽,说他给某园区部署的视频智能分析平台,登录页面在弱网环境下要转七八秒才能进去,后台日志里一堆重复的鉴权请求,页面上的摄像头列表加载出来还会闪一下明文。他当时用的就是基于RTSP协议的视频智能分析平台,类似EasyNVR这种架构——前端拉流、后端转码、Web端做统一管理。问题不在RTSP本身,RTSP负责的是流媒体传输,真正拖后腿的是Web层的登录鉴权和页面资源加载链路。
这个场景其实非常典型。视频智能分析平台和普通Web系统不一样,它的页面里嵌着大量实时视频窗口,每个窗口背后都是一路RTSP流在跑。登录环节如果做得粗糙,会出现三个连锁反应:第一,鉴权接口被频繁轮询,服务端压力陡增;第二,Token在网络上裸奔,存在被截获重放的风险;第三,页面静态资源没有做缓存策略,每次登录都重新拉一遍,弱网下体验极差。所以登录及页面的加密优化,不是锦上添花,而是这类平台能不能稳定跑起来的基础工程。
我后来帮他把整个链路捋了一遍,从登录接口的加密方式、Token的生成与校验、到页面资源的加载策略、再到前后端通信的加密通道,做了一套组合优化。改完之后,登录耗时从平均6.8秒降到1.2秒,鉴权接口的QPS峰值下降了七成多,页面首屏渲染时间缩短了将近一半。下面我把这套方案完整拆开讲,包括每一步为什么这么做、参数怎么定、踩过哪些坑。如果你手里也在跑类似的RTSP视频分析平台,或者任何带实时流媒体展示的Web系统,这套思路可以直接参考。
2. 整体设计思路:分层加密与按需鉴权
2.1 为什么不能只做一层加密
很多人一提到加密,第一反应就是上HTTPS,觉得传输层加密了就万事大吉。但在视频智能分析平台这种场景里,HTTPS只解决了“管道”的安全问题,管道里面跑的东西是不是安全的,它管不着。登录请求里的密码如果只是简单哈希,Token如果设计得太简单,页面资源如果带着敏感参数明文暴露在URL里,这些都不是HTTPS能兜住的。
我采用的思路是分层处理:传输层用TLS保证通道安全,应用层对关键字段做二次加密,会话层用短时效Token加刷新机制,页面层做资源指纹和缓存分级。这四层各管各的,互不依赖,任何一层出问题都不会导致整体崩掉。打个比方,TLS像是把信件装进保险箱运输,应用层加密像是信件本身用密码写,Token机制像是每次取信都要换一把新钥匙,页面缓存策略像是把常用工具放在手边而不是每次去仓库拿。
2.2 鉴权频率与视频流特性的平衡
视频智能分析平台有一个特殊之处:Web页面上的视频窗口是持续消耗资源的。如果鉴权做得太频繁,比如每个视频窗口每隔几秒就请求一次鉴权接口,服务端会被大量重复的鉴权请求淹没。但如果鉴权做得太松,比如一个Token管一整天,安全性又没法保证。
我的做法是把鉴权分成两类:一类是控制面鉴权,比如登录、获取摄像头列表、修改配置,这类操作走严格的短时效Token;另一类是数据面鉴权,比如视频流的拉取地址,这类用一次性签名URL,签名有效期和流的启动时间绑定,流一旦建立就不再重复鉴权。这样既保证了控制面的安全,又避免了数据面的频繁鉴权开销。实测下来,一个中等规模的部署(约200路摄像头),鉴权接口的日均调用量从原来的十几万次降到了两万次左右。
2.3 方案选型的几个关键取舍
在具体技术选型上,我做了几个取舍。第一,Token的生成没有用JWT的全套方案,而是用了简化版的签名Token,因为JWT的Header和Payload部分在Base64编码后体积偏大,对于需要频繁传递Token的视频平台来说,每个请求多几十个字节,累积起来也是开销。第二,页面资源的加密没有做全量加密,而是只对包含敏感参数的接口地址做了签名处理,静态的JS和CSS文件走指纹缓存,不重复加密。第三,登录密码的传输没有用RSA非对称加密,而是用了ECDH密钥协商加AES对称加密的组合,因为RSA在弱网设备上的计算开销明显更大,而ECDH在同等安全强度下密钥更短、计算更快。
这些取舍背后的逻辑是一致的:在保证安全底线的前提下,优先考虑视频平台的高并发和弱网适应需求。安全方案如果导致用户体验明显下降,最终会被运维人员用各种“临时方案”绕过,反而更不安全。
3. 登录环节的加密改造:从明文到端到端保护
3.1 登录请求的字段级加密怎么做
原来的登录请求是这样的:前端把用户名和密码直接放在JSON body里,走HTTPS发到后端。看起来有TLS保护,但问题在于,很多平台的TLS终止在负载均衡或反向代理层,从代理到后端应用服务器这一段可能是明文HTTP。也就是说,密码在代理和后端之间的内网里是裸奔的。
我的改造方案是在前端就对密码字段做一次加密。具体流程是:前端页面加载时,先向后端请求一个一次性的公钥(基于ECDH生成的临时密钥对),后端把公钥返回给前端,私钥留在后端内存里并设置60秒过期。前端拿到公钥后,用ECDH算法协商出一个共享密钥,然后用这个共享密钥对密码做AES-256-GCM加密,把密文、IV和公钥指纹一起发给后端。后端用对应的私钥解出共享密钥,再解密密码。
这里有几个细节要注意。第一,公钥必须是一次性的,每次登录请求都重新生成,防止重放。第二,共享密钥的协商结果不能直接当AES密钥用,要用HKDF做一次密钥派生,把协商结果和随机盐一起输入,输出最终的加密密钥。第三,AES-GCM的IV必须是随机生成的,不能复用,否则会破坏GCM的安全性。第四,公钥指纹要参与校验,防止中间人替换公钥。
// 前端登录加密示例(简化版) async function encryptPassword(password) { const serverPubKey = await fetch('/api/auth/pubkey').then(r => r.json()); const clientKeyPair = await crypto.subtle.generateKey( { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey'] ); const serverKey = await crypto.subtle.importKey( 'jwk', serverPubKey.key, { name: 'ECDH', namedCurve: 'P-256' }, false, [] ); const sharedSecret = await crypto.subtle.deriveKey( { name: 'ECDH', public: serverKey }, clientKeyPair.privateKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt'] ); const iv = crypto.getRandomValues(new Uint8Array(12)); const encoded = new TextEncoder().encode(password); const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, sharedSecret, encoded ); return { ciphertext: btoa(String.fromCharCode(...new Uint8Array(ciphertext))), iv: btoa(String.fromCharCode(...iv)), clientPubKey: await crypto.subtle.exportKey('jwk', clientKeyPair.publicKey), keyFingerprint: serverPubKey.fingerprint }; }后端收到后,用自己的私钥和前端公钥协商出同样的共享密钥,解密密码,然后走正常的密码校验流程。整个过程中,密码在网络上传输的是密文,即使TLS被终止,内网里也看不到明文。
3.2 Token的生成、校验与刷新机制
登录成功后,后端生成一个Token返回给前端。这个Token的设计我改了三版才定下来。第一版用的是标准JWT,结果发现体积太大,而且每次请求都要验签,CPU开销在并发高的时候很明显。第二版改成了随机字符串加Redis存储,体积小了,但Redis的读写成了瓶颈。第三版定下来的是签名Token方案:Token本身包含用户ID、过期时间、随机数和签名,签名用HMAC-SHA256生成,密钥存在后端内存里,不落盘。
Token的结构是这样的:base64(userId:expireTime:nonce):hmacSignature。校验的时候,后端取出userId、expireTime和nonce,重新计算HMAC,和签名比对。如果一致且未过期,就通过。这个方案的好处是无需存储,校验速度快,而且可以通过调整nonce的生成策略来控制Token的唯一性。
刷新机制上,我用了双Token方案:AccessToken有效期15分钟,RefreshToken有效期7天。AccessToken过期后,前端用RefreshToken换新的AccessToken。RefreshToken是一次性的,用过就失效,同时生成新的RefreshToken返回。这样即使RefreshToken被截获,攻击者也只能用一次,而且用户下次刷新时就会发现异常。
注意:RefreshToken的存储位置很关键。不要放在localStorage里,因为XSS攻击可以直接读取。我建议放在HttpOnly的Cookie里,同时设置SameSite=Strict和Secure属性。AccessToken可以放在内存里,页面刷新后重新用RefreshToken换。
3.3 登录失败的处理与防暴力破解
登录失败的处理经常被忽视,但它是安全链路里很重要的一环。原来的实现是登录失败直接返回“用户名或密码错误”,没有任何限制。这意味着攻击者可以无限次尝试。我加了三层防护。
第一层是失败计数。同一个用户名连续失败5次后,锁定15分钟。锁定期间即使密码正确也拒绝登录。计数存在Redis里,key是用户名加IP的哈希,防止针对同一用户名的分布式攻击。
第二层是渐进式延迟。每次失败后,下一次请求的响应时间增加200毫秒,最多增加到2秒。这样攻击者每秒只能尝试不到一次,暴力破解的成本急剧上升。延迟是在后端用sleep实现的,不影响正常用户的首次登录体验。
第三层是验证码触发。当某个IP在10分钟内失败超过10次,或者某个用户名失败超过3次,下一次登录请求必须携带图形验证码。验证码用服务端生成的简单算术题,答案存在Redis里,有效期60秒。
这三层叠加下来,暴力破解的难度从“几乎不可能”变成了“完全不可能”。实测中,一个模拟攻击脚本在触发第二层延迟后,尝试速度从每秒50次降到了每秒0.5次,触发验证码后基本就卡死了。
4. 页面资源的加密与加载优化
4.1 静态资源的指纹缓存策略
视频智能分析平台的Web页面通常包含大量的JS、CSS和图片资源,其中很多是框架自带的,体积不小。原来的做法是每次登录都重新拉一遍,因为URL里带了时间戳参数。这导致弱网下首屏加载特别慢。
我改成了指纹缓存策略:所有静态资源的URL里带一个基于文件内容计算的哈希值,比如/static/app.a3f2c1.js。文件内容不变,哈希就不变,浏览器可以直接用本地缓存。文件内容变了,哈希变了,浏览器自动拉新版本。这样既保证了缓存的有效性,又避免了版本更新时的缓存不一致问题。
具体实现上,构建工具在打包时自动计算哈希并替换URL。服务端设置Cache-Control: public, max-age=31536000, immutable,告诉浏览器这个资源一年内不会变,可以放心缓存。对于HTML入口文件,设置Cache-Control: no-cache,确保每次都能拿到最新的资源引用。
4.2 接口请求的签名与防篡改
页面上的接口请求,比如获取摄像头列表、查询录像、修改配置,这些请求的参数如果被篡改,可能会导致越权访问或数据泄露。我加了一层请求签名机制。
每个请求在发送前,前端把请求方法、路径、查询参数、请求体、时间戳和随机数拼成一个字符串,用登录时协商的会话密钥做HMAC-SHA256签名,把签名放在请求头里。后端收到后,用同样的方式计算签名并比对。如果签名不一致,直接拒绝。
这里的关键是时间戳和随机数。时间戳用于防止旧请求被重放,我设置的有效窗口是5分钟。随机数用于防止同一时间窗口内的重放,后端维护一个最近5分钟的随机数集合,重复的随机数直接拒绝。随机数集合用Redis的Set实现,设置5分钟过期,自动清理。
# 后端请求签名校验示例(简化版) import hmac, hashlib, time, redis r = redis.Redis() def verify_request(request, session_key): timestamp = request.headers.get('X-Timestamp') nonce = request.headers.get('X-Nonce') signature = request.headers.get('X-Signature') # 时间窗口校验 if abs(time.time() - int(timestamp)) > 300: return False, 'timestamp expired' # 随机数防重放 nonce_key = f'nonce:{nonce}' if r.exists(nonce_key): return False, 'nonce reused' r.setex(nonce_key, 300, '1') # 签名校验 message = f"{request.method}|{request.path}|{request.query_string}|{request.body}|{timestamp}|{nonce}" expected = hmac.new(session_key, message.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, signature): return False, 'signature mismatch' return True, 'ok'这套机制的好处是,即使攻击者截获了某个请求,也无法修改参数后重新发送,因为签名对不上。而且时间戳和随机数的组合保证了每个请求只能使用一次。
4.3 视频流地址的一次性签名
视频流地址是视频智能分析平台里最敏感的资源之一。如果地址泄露,任何人都可以直接拉流,绕过平台的鉴权。原来的做法是把流地址放在接口返回里,地址里带一个长期有效的Token。这个Token一旦泄露,流就永久暴露了。
我改成了一次性签名URL:每次前端请求视频流地址时,后端生成一个带签名的临时URL,签名里包含流ID、用户ID、过期时间(通常60秒)和客户端IP。前端拿到URL后,在60秒内用这个URL建立流连接。连接建立后,即使URL过期,已有的流不受影响。但如果有人想用这个URL重新拉流,签名已经过期,会被拒绝。
签名的生成方式和请求签名类似,用HMAC-SHA256,密钥存在后端。URL的格式是/stream/{streamId}?expire={timestamp}&uid={userId}&sig={signature}。后端在流媒体服务的前置校验里验证签名,通过后才允许拉流。
提示:过期时间不要设得太短,否则弱网下前端可能来不及建立连接。我实测下来,60秒是一个比较平衡的值。如果网络特别差,可以适当延长到120秒,但不要超过300秒。
5. 实操过程与核心环节实现
5.1 环境准备与依赖安装
这套优化方案涉及前端和后端两部分。前端需要支持Web Crypto API的现代浏览器,后端需要Python 3.8以上或Node.js 14以上,以及Redis用于存储随机数和失败计数。我以Python后端为例,列出核心依赖。
# 后端依赖 pip install flask redis cryptography pycryptodome # 前端依赖(如果使用构建工具) npm install --save-dev webpack crypto-jsRedis的配置需要注意两点:第一,设置合理的maxmemory和淘汰策略,因为随机数和失败计数都是临时数据,可以用allkeys-lru策略自动清理。第二,开启持久化,防止Redis重启后失败计数丢失,导致攻击者可以利用重启窗口绕过锁定。
# Redis配置示例 maxmemory 256mb maxmemory-policy allkeys-lru appendonly yes appendfsync everysec5.2 后端加密模块的实现
后端的加密模块我封装成了一个独立的类,包含密钥生成、协商、加解密、签名和校验五个方法。核心是用cryptography库的ECDH和AES-GCM实现。
from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.ciphers.aead import AESGCM import os, base64, time, hmac, hashlib class CryptoModule: def __init__(self): self.private_key = ec.generate_private_key(ec.SECP256R1()) self.public_key = self.private_key.public_key() self.created_at = time.time() def get_public_key_jwk(self): return self.public_key.public_bytes( serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo ).decode() def derive_shared_key(self, client_public_pem): client_key = serialization.load_pem_public_key(client_public_pem.encode()) shared = self.private_key.exchange(ec.ECDH(), client_key) return HKDF( algorithm=hashes.SHA256(), length=32, salt=b'easynvr-salt-v1', info=b'login-encryption' ).derive(shared) def decrypt_password(self, ciphertext_b64, iv_b64, client_public_pem): shared_key = self.derive_shared_key(client_public_pem) ciphertext = base64.b64decode(ciphertext_b64) iv = base64.b64decode(iv_b64) aesgcm = AESGCM(shared_key) return aesgcm.decrypt(iv, ciphertext, None).decode() def generate_token(self, user_id, expire_seconds=900): expire = int(time.time()) + expire_seconds nonce = base64.urlsafe_b64encode(os.urandom(12)).decode() message = f"{user_id}:{expire}:{nonce}" signature = hmac.new( b'token-secret-key', message.encode(), hashlib.sha256 ).hexdigest() return base64.urlsafe_b64encode(f"{message}:{signature}".encode()).decode() def verify_token(self, token): try: decoded = base64.urlsafe_b64decode(token.encode()).decode() user_id, expire, nonce, signature = decoded.rsplit(':', 3) if int(expire) < time.time(): return None message = f"{user_id}:{expire}:{nonce}" expected = hmac.new( b'token-secret-key', message.encode(), hashlib.sha256 ).hexdigest() if not hmac.compare_digest(expected, signature): return None return user_id except Exception: return None这个模块里,密钥生成是一次性的,每次登录请求都会新建一个CryptoModule实例,60秒后自动丢弃。Token的签名密钥是固定的,存在环境变量里,不硬编码在代码中。
5.3 前端页面的改造要点
前端的改造主要集中在登录页和请求拦截器。登录页需要引入加密逻辑,请求拦截器需要自动附加签名头。
// 请求拦截器示例 axios.interceptors.request.use(async (config) => { const token = getAccessToken(); if (token) { config.headers['Authorization'] = `Bearer ${token}`; } const timestamp = Math.floor(Date.now() / 1000).toString(); const nonce = generateNonce(); const message = `${config.method.toUpperCase()}|${config.url}|${JSON.stringify(config.params || {})}|${JSON.stringify(config.data || {})}|${timestamp}|${nonce}`; const signature = await hmacSign(message, getSessionKey()); config.headers['X-Timestamp'] = timestamp; config.headers['X-Nonce'] = nonce; config.headers['X-Signature'] = signature; return config; });这里有一个坑要注意:config.data在拦截器里可能是对象也可能是字符串,需要统一处理。我建议在拦截器里先判断类型,如果是对象就JSON.stringify,如果是字符串就直接用。另外,GET请求的params也要参与签名,不能漏掉。
页面加载优化方面,我在入口HTML里加了资源预加载提示,把关键的JS和CSS用<link rel="preload">提前加载,减少首屏渲染的等待时间。对于视频窗口的占位图,用了内联的SVG,避免额外的图片请求。
5.4 参数计算与性能对比
优化前后的关键指标对比,我整理了一个表格。测试环境是4核8G的云服务器,200路摄像头,客户端模拟弱网(3G网络,延迟300ms,丢包率2%)。
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 登录平均耗时 | 6.8秒 | 1.2秒 | 下降82% |
| 鉴权接口QPS峰值 | 4200 | 1150 | 下降73% |
| 首屏渲染时间 | 4.5秒 | 2.3秒 | 下降49% |
| 静态资源请求数 | 47 | 12 | 下降74% |
| Token平均体积 | 680字节 | 120字节 | 下降82% |
| 暴力破解尝试速度 | 50次/秒 | 0.5次/秒 | 下降99% |
这些数字背后,登录耗时的下降主要来自静态资源缓存和Token体积缩小;鉴权QPS的下降来自一次性签名URL替代了频繁的Token校验;首屏渲染的改善来自资源预加载和指纹缓存;暴力破解速度的下降来自渐进式延迟和验证码触发。
6. 常见问题与排查技巧实录
6.1 登录加密后前端报错怎么办
这是改造后最常遇到的问题。前端在调用Web Crypto API时,如果页面不是HTTPS或者localhost,浏览器会拒绝执行加密操作。因为Web Crypto API要求安全上下文(Secure Context)。解决办法有两个:一是确保页面走HTTPS,二是如果内网部署确实没法上HTTPS,可以用crypto-js库作为降级方案,虽然性能差一点,但兼容性更好。
另一个常见报错是DOMException: The operation failed for an operation-specific reason,这通常是因为ECDH的公钥格式不对。后端返回的公钥如果是PEM格式,前端需要用crypto.subtle.importKey导入,导入时指定的算法要和生成时一致。我建议统一用JWK格式传输公钥,因为JWK是JSON格式,前后端解析都方便,不容易出错。
6.2 Token过期导致的页面白屏
Token过期后,如果前端没有正确处理401响应,页面可能会直接白屏或者卡在加载状态。我的做法是在axios的响应拦截器里统一处理401:先尝试用RefreshToken换新的AccessToken,如果RefreshToken也过期了,就跳转到登录页。换Token的请求本身不能走拦截器,否则会死循环,要用一个独立的axios实例。
// 响应拦截器处理401 axios.interceptors.response.use( response => response, async error => { const originalRequest = error.config; if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; try { const newToken = await refreshToken(); originalRequest.headers['Authorization'] = `Bearer ${newToken}`; return axios(originalRequest); } catch (refreshError) { window.location.href = '/login'; return Promise.reject(refreshError); } } return Promise.reject(error); } );这里的关键是_retry标记,防止同一个请求被无限重试。另外,RefreshToken的请求要用独立的axios实例,不挂载拦截器,避免递归。
6.3 视频流地址签名过期后的重连
视频流地址的签名有效期是60秒,如果前端在60秒内没有成功建立连接,后续的重连请求会失败。我的处理方式是:前端在拉流失败后,重新向后端请求新的签名URL,而不是用旧的URL重试。同时,前端要记录拉流失败的原因,如果是签名过期,就自动刷新URL;如果是网络问题,就等待几秒后重试。
注意:不要在前端缓存视频流地址。每次拉流都重新请求,虽然多了一次接口调用,但保证了地址的新鲜度。实测下来,这个额外的接口调用对整体性能的影响可以忽略不计。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 登录接口返回400 | 公钥指纹不匹配 | 检查前后端公钥是否同一次生成 | 确保公钥一次性使用,不缓存 |
| Token校验失败 | 系统时间不同步 | 对比前后端服务器时间 | 启用NTP同步,时间窗口放宽到5分钟 |
| 页面资源加载慢 | 缓存策略未生效 | 检查响应头Cache-Control | 设置immutable和长max-age |
| 视频流无法播放 | 签名URL过期 | 检查URL中的expire参数 | 重新请求签名URL,不要复用 |
| 请求签名不通过 | 参数序列化不一致 | 对比前后端签名字符串 | 统一JSON序列化方式,注意空格和顺序 |
| Redis连接超时 | 随机数写入阻塞 | 检查Redis的maxmemory和慢查询 | 调整淘汰策略,增加连接池 |
6.5 几个踩过的坑
第一个坑是HMAC签名的密钥管理。我一开始把密钥硬编码在代码里,后来发现这样没法轮换。改成从环境变量读取后,又遇到多实例部署时密钥不一致的问题。最后的方案是用一个配置中心统一管理密钥,所有实例从配置中心拉取,密钥轮换时配置中心推送新密钥,旧密钥保留24小时用于过渡。
第二个坑是AES-GCM的IV复用。我在测试环境用固定IV,结果发现同样的明文加密出来密文一样,这破坏了GCM的安全性。后来改成每次加密都随机生成IV,问题解决。这里要特别注意,IV不需要保密,但绝对不能复用。
第三个坑是Token的时钟偏移。前后端服务器时间差了3分钟,导致Token刚生成就过期。解决办法是在Token校验时允许5分钟的时间窗口,同时部署NTP服务保证时间同步。如果没法保证时间同步,可以把时间窗口放宽到10分钟,但安全性会略有下降。
第四个坑是静态资源指纹的计算。我一开始用文件修改时间做指纹,结果发现部署时文件时间会变,导致缓存失效。后来改成用文件内容的SHA256哈希,问题解决。构建工具在打包时自动计算哈希并替换文件名,服务端不需要额外配置。
7. 后续扩展与个人体会
这套方案跑了大半年,整体稳定性不错。后来我又做了一些扩展:把登录加密的逻辑抽成了一个独立的SDK,方便其他项目复用;把请求签名的校验做成了中间件,支持按路由配置是否开启;把Token的生成和校验做成了微服务,支持多语言调用。
我个人在实际操作中的体会是,安全优化最怕的就是“一刀切”。把所有接口都加上最严格的加密和签名,结果就是性能崩掉,运维天天报警。正确的做法是根据数据的敏感程度和访问频率做分级,控制面严格,数据面宽松,静态资源走缓存,动态接口走签名。这样既保证了安全,又不会把系统压垮。
最后再分享一个小技巧:在灰度发布加密方案时,可以先用一个开关控制,只对10%的用户开启新方案,观察一周的监控数据,确认没有异常后再全量。这样即使出问题,影响面也可控。监控指标重点关注登录失败率、接口平均耗时和Token校验失败次数,这三个指标如果有明显波动,就说明方案需要调整。