ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

亚马逊广告API Token过期:请求级刷新与分布式锁实战

亚马逊广告API Token过期:请求级刷新与分布式锁实战 凌晨两点半被监控电话叫醒打开日志看到一排 401这种体验我到现在都记得。当时跑的是亚马逊广告 API 的批量报表任务第 37 广告组卡住了后台显示 Access Token 明明在任务启动时刚换的怎么四个小时没跑完就开始大面积报错。查了一圈才明白Amazon Advertising API 的 Access Token 只有 1 小时寿命而我的任务却想让它加班到天亮。这类问题并不是我一个人遇到。凡是接亚马逊广告 API 做投放工具、报表系统、自动化优化的团队几乎都会被 1 小时过期问题折磨过。很多人第一反应是加个定时器每三十分钟刷新一次但真上了生产就会发现定时刷新救不了长任务、多实例和多账号场景。这篇文章想分享的是我把 Token 管理下沉到「每个请求」之后彻底解决过期问题的一套完整设计思路、并发刷新方案以及线上踩过的坑希望能让后面接手的人少走几条弯路。1. 1 小时过期为什么专挑批处理任务下手1.1 OAuth 两个 Token 的角色差异是问题根源Amazon 广告 API 走的是 OAuth 2.0 的 Authorization Code 流程授权完成后我们会拿到两种 Token一种叫 Authorized Token也就是接口调用时放在 Header 里的Authorization: Bearer另一种叫 Refresh Token只用于调用 Token 刷新接口换新的 Authorized Token。这两个 Token 的关系可以理解成门禁卡和补卡机门禁卡 1 小时有效过期进不了门补卡机不会过期但你不能指望直接拿补卡机去刷所有门禁。为什么 Amazon 要把 Authorized Token 的有效期压到 1 小时主要出于安全考虑。短寿命 Token 即使被截获攻击者拿到手也已经过期能造成的破坏很有限。相比之下Refresh Token 因为能持续换新被严格限制只能用于后端 Server 到 Server 调用不能放在浏览器或移动端。这个安全设计本身没什么问题真正麻烦的是它给批处理任务制造了一个几十上百次的「中途失效」场景。1.2 失效时刻的三种典型现场我梳理过自己遇到和见过的失效场景大致能分成三类第一类是长跑任务中途失效。报表回捞、关键词调价、预算调整这类任务经常一次跑几个小时Token 在任务启动时有效但第 50 分钟开始就陆续失效。由于任务内部可能已经开了多线程、异步队列甚至多个子进程Token 是启动时一次性取好的后面的请求只能拿着过期 Token 硬闯。第二类是子进程或 Worker 复用旧 Token。比如你在主进程里刷新了 Token但消息队列里的某个 Worker 还保存着刷新前的旧值或者你用multiprocessing派生子进程子进程从父进程继承的内存里拿到了已经过期的 Token。这类问题的排查难度比第一种高因为日志里看不到明确的重试逻辑。第三类是多账号循环任务互相拖累。代理投放场景经常循环处理几十个广告账号每个账号有自己的 Token。如果代码里把 Token 存成单个全局变量循环到后面账号时前面账号的 Token 已经被覆盖紧接着调上一个账号的接口就会全部 401。这三种现场带来的共同教训是Token 过期不是一个「到点触发一次」的事件而是一个「任何时刻都可能被某个请求撞上」的事实。定时刷新的思维方式天然就漏掉了这一层。2. 定时刷新和进程内缓存为什么总是差一口气2.1 定时器、全局变量撑不住的三个场景最常见的解法是开一个定时任务每 30 分钟调一次 Token 接口换新。单机单账号、任务量小的时候确实够用但一到真实业务里就有三个撑不住的场景。第一个是进程重启丢状态。很多新手把 Token 放在进程内变量里任务进程重启内存清空定时器还没到点新进程拿什么去调接口有些人会把 Token 序列化到本地文件但多实例部署时文件内容又被互相覆盖。第二个是长任务执行时间超过 Timer 间隔。比如你每 30 分钟刷新一次但单个批量任务要跑 80 分钟任务内发的请求用的是「任务开始时拿到的旧 Token 引用」定时器即使刷新了任务也开始继续用新值了所有预先构造好的 URL 和 Header 都用旧值。第三个是任务调度本身把刷新「饿死」。假设你的任务队列里有大量请求排队等 Token所有请求都卡在获取同一个 Token那么必然有人拿到时它已经剩不到几分钟真实请求发出去的时候刚好过期。这里插一句我在维护某个调度系统时遇到过最无语的定时刷新成功把 Redis 里的 Token 更新了但批量任务在内存里缓存了 Token 副本压根不读 Redis。这个属于架构层面的 Cache 穿透问题比单纯过期更隐蔽。2.2 多实例各自刷新带来的互相覆盖只要部署了超过一个实例进程内缓存和定时刷新多版本的问题立刻暴露。两个 Worker 同时发现 Token 快过期几乎同时发起刷新请求LWA Token 端点返回两个新 Token。这两个 Token 都是有效的但 Redis 里只能保留一个。更麻烦的是两个实例可能各自持有不同 Token 一段时间请求流量在某切换节点上就会偶发 401。这就像两个人同时去补办门禁卡发卡中心给了两张不同的新卡但闸机系统只认其中一张。你以为换发成功了实际上有一半请求还在用旧卡的缓存副本。单实例时代还能靠进程锁糊弄过去多实例之后就必须依赖外部协调组件。Redis 的分布式锁也好数据库的行级锁也好关键是让同一账号同一时刻有且只有一个刷新动作。2.3 从「定时刷新」到「请求时裁决」的思维转变真正让我扭转思路的是一次线上事故复盘。那次的根因是定时刷新任务依赖的系统时间比 Token 服务器慢了 4 分钟导致刷新回来的 Token 本地判定还有效实际已过期。这让我意识到Token 的过期不是一个数字而是服务端对凭证的实时校验结果。与其在后台猜「现在是不是该刷新」不如把它改成「每次发起请求前基于当前时间做一次裁决」。所谓请求级 Token并不是每一请求都去调刷新接口而是把 Token 的有效性判断、刷新动作、失败重试全部内嵌到请求发起的路径里。请求发起时先看这个 Token 是否足够新鲜不够就走刷新链路刷新不来就判断是该等待、该重试、还是该放弃。这才是从根上解决 1 小时过期问题的思路。3. 请求级 Token 设计的核心缓存、裁决、刷新三分离3.1 数据模型不单存 Token还存过期时间很多项目把 Token 当作普通字符串存 Redis刷新时直接SET覆盖。这种设计丢掉了两个关键信息过期时间点和最后刷新时间。没有过期时间请求级裁决就无从谈起没有刷新时间你就没法判断是不是最近刚刷新过比如刚刷新完又立刻重复刷新通常意味着系统时间或逻辑有 bug。我自己搭建 Token Store 时用 JSON 结构K-V 映射大致如下{ access_token: xxx..., refresh_token: yyy..., expires_at: 1710000000, last_refresh_at: 1709996400, refresh_count: 12 }expires_at虽然是「预估过期时间」但核心判断依据。计算方式为「拿到 Token 时的时间 3600 秒 - 安全余量」。refresh_token之所以要跟着存是因为 Amazon 在部分授权场景下可能在刷新时返回新的 Refresh Token你不存下来就只能永远用旧的。refresh_count用于监控异常刷新频率正常情况下每个账号每小时最多刷新一次超过就说明有可能是多个实例在抢刷新锁。Redis Key 的命名建议直接包含账号唯一标识和区域端点例如ams:token:na:advertiser_123456。3.2 裁决规则提前量、硬过期、刷新窗口裁决逻辑看起来就几行if但阈值设置是有讲究的。经验做法是不等到真正过期才去刷新而是设置一个提前量常见的有 300 秒5 分钟。原因有三个一是服务器时间可能存在偏差。你的 NTP 同步再准也有毫秒级抖动更别说部分云厂商的容器实例时钟偏移曾到过分钟级。 二是刷新接口本身也有延迟。如果 Token 还剩 10 秒你就去调刷新接口网络抖动一下等新 Token 回来时旧 Token 已经正式失效中间所有请求都会 401。 三是留给人一个纠错窗口。一旦系统时钟出问题或者 LWA 临时性故障你至少在过期前还有 5 分钟的时间去处理告警而不是直接整个任务崩掉。裁决流程可以分成三档剩余有效期大于 300 秒直接使用缓存里的 Access Token。剩余有效期在 0 到 300 秒之间触发刷新但在刷新完成之前用旧 Token 作为 fallback。剩余有效期小于 0必须先刷新才能继续同时记录一次过期事件用于告警。3.3 刷新链路的完整代码流程用 Python 伪码写的核心 TokenManager 大概是这个结构import time from abc import ABC, abstractmethod class TokenStore(ABC): abstractmethod def load(self, namespace: str): pass abstractmethod def save(self, namespace: str, bundle): pass class TokenManager: REFRESH_AHEAD_SECONDS 300 # 提前 5 分钟刷新 HARD_EXPIRE_PADDING 60 # 硬过期兜底 def __init__(self, store, oauth_client): self.store store self.oauth_client oauth_client def get_token(self, namespace: str) - str: bundle self.store.load(namespace) now int(time.time()) if bundle is None: bundle self._refresh_from_scratch(namespace) remaining bundle[expires_at] - now if remaining self.REFRESH_AHEAD_SECONDS: return bundle[access_token] # 剩余时间不足尝试刷新 new_bundle self._refresh_with_lock(namespace, bundle) return new_bundle[access_token] def _refresh_with_lock(self, namespace, bundle): # 实际刷新逻辑在下一章展开 raise NotImplementedError这里有几个细节要展开说第一_refresh_from_scratch出现在缓存完全为空的时候。虽然正常情况下通过授权流程拿到的 Refresh Token 会被提前写入 Redis但如果缓存被人为清掉又不能重新触发 OAuth 完整流程的话你可能需要临时用数据库里持久化的 Refresh Token 重新构建 bundle。第二expires_at不是从 LWA 响应里的expires_in简单相加。因为响应到达本机还要几十毫秒延迟所以更稳妥的做法是expires_at int(time.time()) expires_in - self.HARD_EXPIRE_PADDING这里的 60 秒 padding 属于保守派设置它会让 Token 提前一分钟作废。损失一点 Token 寿命完全无所谓但能避免在临界点上的偶发 401。第三刷新完以后不能只更新 Access TokenRefresh Token 和过期时间也要一起更新。部分授权场景刷新会返回全新 Refresh Token只更新前者会把新 Refresh Token 丢掉等到旧 Refresh Token 被 LWA 吊销后授权就彻底断了。经过这一层设计以后批处理任务里每个请求在发起前都会走一遍「够新鲜就直接用不够新鲜就刷新」的路径。这已经能解决绝大多数单实例下的 1 小时过期问题但线上跑起来之后又发现了新的并发难题。4. 并发刷新风暴分布式锁与 401 重试兜底4.1 为什么多个请求会同时刷新同一个 Token请求级裁决虽然解决了过期问题但它会把刷新动作从「每小时一次」变成「频率不确定」。一旦任务并发量高比如报表系统有 20 个线程同时处理不同广告组而它们缓存里的 Token 恰好都在同一时间点剩下不到 5 分钟那这 20 个线程可能同时发现「需要刷新」然后一起调 LWA 接口。T 时刻同时来了 20 个并发刷新请求的结果是LWA 对同一个 Client ID 有频率限制一次爆发式请求很可能触发限流导致部分刷新失败。多实例场景下如果刷新流程没有互斥两个新 Token 会互相覆盖刷新完成时机不同还会造成短时间内的混用。每次刷新 Token 都要经历一次网络往返和密码学计算无脑并发刷新会占用宝贵的 OAuth 配额。请求级 Token 设计的真谛不是「每个请求各自去刷新」而是「每个请求在发现需要刷新时只会有一个请求真正执行刷新其余请求等待或使用兜底方案」。4.2 Redis 锁加 double-check 的完整实现我在实现_refresh_with_lock时用的方案是 Redis 的SET key value NX EX原子指令。核心思想非常简单谁拿到锁谁就去刷新没拿到锁的线程等一小会儿后重新读缓存如果缓存里已经是新 Token 就直接用这就是 double-check。import time import uuid class RedisLockedTokenManager(TokenManager): LOCK_TIMEOUT_SECONDS 10 def __init__(self, store, redis_client, oauth_client): super().__init__(store, oauth_client) self.redis redis_client def _refresh_with_lock(self, namespace, old_bundle): lock_key fams:token:lock:{namespace} lock_value f{uuid.uuid4().hex}-{int(time.time())} if not self.redis.set(lock_key, lock_value, nxTrue, exself.LOCK_TIMEOUT_SECONDS): return self._wait_or_fallback(namespace, old_bundle) try: new_bundle self._call_token_endpoint(namespace) self.store.save(namespace, new_bundle) return new_bundle finally: # 仅当锁还是自己的时候才释放避免误删别人重新加的锁 if self.redis.get(lock_key) lock_value: self.redis.delete(lock_key) def _wait_or_fallback(self, namespace, old_bundle): for _ in range(30): time.sleep(0.2) latest self.store.load(namespace) now int(time.time()) if latest and latest[expires_at] - now self.REFRESH_AHEAD_SECONDS: return latest # 拿锁等了 6 秒还没好降级用旧 Token 发一次请求通过下游 401 重试来兜底 return old_bundle几个要注意的细节RedisSET NX EX必须是原子操作不能用先SETNX再EXPIRE两条命令替代否则加锁成功但进程崩溃可能导致锁永远不释放。释放锁之前要验证锁 Value 是不是自己写入的那个这是常规防误删手段。因为持有锁的请求可能因为 GC 停顿或网络阻塞超过 10 秒锁自动过期后被别的线程拿到这时你直接DEL会把别人的锁删掉。_wait_or_fallback里写死等待 30 次、每次 0.2 秒是为了避免无限阻塞。实际生产里可以根据任务超时时间调整比如 1 秒内等不到就直接拿旧 Token 发请求。这套实现上线以后并发刷新风暴明显消失LWA 的限流告警也再没触发过。但光有锁还不够还要处理一个尴尬情况旧 Token 已经过期了可因为网络原因刷新一直没成功请求到底要不要发4.3 加一层 401 重试给最后一道兜底我的选择是无论请求级裁决判断结果如何真到了发送 HTTP 请求这步都对返回 401 的认证失败做一次「强制刷新后重试」。class ApiClient: def __init__(self, token_manager): self.token_manager token_manager def request(self, method, path, namespace, payloadNone): token self.token_manager.get_token(namespace) resp self._raw_request(method, path, token, payload) if resp.status_code 401 and self._is_auth_error(resp): # 强制绕过正向缓存直接刷新 new_token self.token_manager.force_refresh(namespace) resp self._raw_request(method, path, new_token, payload) return resp为什么只重试一次因为如果刷新逻辑和请求逻辑本身没问题重试一次足够解决几乎所有过期导致的 401。如果重试一次还是 401那大概率不是过期问题而是 Refresh Token 被吊销、账号授权被撤销或者请求签名有问题。无限重试只会放大故障影响尤其是批量任务里数据写入类接口比如预算调整、关键词状态变更重复执行可能有副作用。这里还要注意一个请求语义问题如果你调用的接口不幂等遇到「收到 401 但服务端其实已处理成功」的场景重试就会造成重复提交。Amazon 广告 API 大部分读接口是安全的但像 budget update 这类写接口就要自己在业务层设计幂等键。锁和重试都安排上以后单账号的稳定性已经基本敲定。但做广告投放系统的人都知道单账号只是入门难度真正考验人的是代理模式下的一堆账号和站点。5. 多账号与多站点部署Token 桶隔离与失效恢复5.1 namespace 设计把区域、账号、授权实体都放进 Key亚马逊广告 API 按区域提供不同的 Endpoint区域Endpoint北美https://advertising-api.amazon.com欧洲https://advertising-api-eu.amazon.com远东https://advertising-api-fe.amazon.com不同区域的授权 Token 彼此不互通用欧洲 Endpoint 换来的 Token 打到北美 API 上直接 401。Token 刷新也不应该跨区域复用因为 LWA Token 端点虽然一样但授权时绑定的 scope 已经和区域绑定。我在实际项目里用的 namespace 规则是ams:token:{region}:{customer_id}:{advertiser_id}regionna / eu / fe。customer_id你们系统内部的客户标识多客户代理场景用来隔离数据。advertiser_id广告主的标识。一个客户下面可能有多个广告账号每个独立授权。这套结构解决的最大问题是资源隔离。如果所有账号共用一个 Key循环处理 50 家客户时刷 A 客户的 Token 去调 B 客户的接口返回的数据错乱程度远大于普通 401而且日志里还看不出逻辑问题。5.2 授权撤销与硬失败识别请求级刷新并不能解决所有 Token 问题有一种情况是刷新也救不回来的Refresh Token 本身被吊销。常见原因是用户手动撤销了授权或者授权有效期到期。Amazon 对 Refresh Token 一般给的是相对长的有效期但再长的授权也有到期的一天。当 LWA 刷新接口返回invalid_grant错误时不能像普通 401 一样直接重试。这个错误代表保险公司已经拒绝续保你再怎么补交材料也没用。正确做法是立即停止该广告账号的所有请求避免无效循环消耗配额。在 Token Store 里打一个硬失败标记后续请求直接返回业务错误。推送告警让运营同学联系客户重新走授权流程。我在系统里给每个 Token 桶加了状态字段active、refresh_failing、revoked。只有active状态才允许走到请求流程其他状态直接短路。5.3 批量任务的断点续跑设计多账号批量任务最容易出现的问题是一个账号失败影响整批任务。因为以前的效果是「日志里一堆 401」根本不知道哪些账号成功哪些失败。请求级 Token 管理落地之后批量任务可以按 namespace 粒度做断点续跑。具体做法是遍历所有待处理账号。每个账号请求前先检查 Token 桶状态状态不健康就跳过而不是反复 401 轰炸。每个账号处理完成后把进度标记到任务表例如completed、failed、token_revoked。任务调度平台定时扫失败列表只对token_revoked之外的账号重试。这套设计下一个客户的授权过期不会影响其他 49 个客户的任务执行运维每天只需要处理告警推送出来的那张「需要重新授权」清单就行。6. 从定时刷新迁移到请求级 Token 的几条实战经验6.1 迁移顺序建议先加缓存再加裁决最后加锁如果你现在已经有现成的定时刷新逻辑不建议一次性全部重写成新的架构。我自己的迁移顺序是三步走每步可以单独发布上线第一步先把 Token 从内存挪到 Redis定时刷新逻辑保留但所有请求改为从 Redis 统一读取。这一步解决多实例各自持有 Token 副本的问题。第二步在读取 Token 的入口加「剩余有效期」判断剩余不足 300 秒先刷新再返回。这时代码里其实已经有了请求级裁决但刷新动作还是单实例在跑。可以观察一段时间看 Redis 里的刷新次数和 401 率是否下降。第三步加 Redis 分布式锁保证多实例并发场景下只有一个实例执行刷新。同时把 401 重试逻辑加进 HTTP Client 层。这一步完成以后定时刷新任务可以彻底删除。别小看这个迁移顺序。因为每步都保持系统可用出问题容易回滚定位。我见过很多人想一把梭重写结果三个问题纠缠在一起排查了两周。分步迁移的最大好处是上线以后 Token 相关告警数量下降你能确定到底是哪一步在起作用。6.2 监控指标和预警阈值请求级 Token 上线后我建议至少盯住这几项指标指标说明预警阈值建议401 重试率重试次数占总请求数比例连续 5 分钟超过 1%刷新频率每个账号每小时刷新次数超过 3 次说明裁决或锁有问题锁等待时间等待 Redis 锁的平均耗时超过 2 秒需要评估锁粒度刷新失败率LWA 返回非 2xx 的比例单账号连续 3 次失败Token 剩余寿命分布请求开始时 Token 平均剩余秒数中位数低于 600s 说明裁决滞后其中「刷新频率」这个指标最容易看出设计是否退化。如果代码写歪了比如漏了 double-check那么每个请求都会刷新一次这个指标的曲线会立刻抬头比看 401 日志直观得多。6.3 我特别想提醒的三个容易忽略的坑第一个是刷新接口返回 Refresh Token 的字段可能是空也可能有值。我在某些 Amazon 授权类型里遇到过刷新后 Refresh Token 没有变化但如果你的代码直接把响应里的 Refresh Token 覆盖进 Redis要保证空字符串不会覆盖旧值。正确的做法是「返回了有值就存新值没返回就保留旧值」。第二个是刷新失败时的指数退避。LWA 偶尔会因为上游抖动返回 5xx这时候立刻重试效果很差。建议至少做 2 次重试间隔分别是 1 秒和 3 秒连续失败再告警。不要一遇到刷新失败就立刻触发「重新授权」99% 的情况不是授权没了只是网络抖了一下。第三个是系统时间问题。前面提到过云容器时钟偏移可能导致本地 Token 剩余寿命判断失真但线上还有一种更隐蔽的情况服务器时间比实际时间快导致 Redis 里的 Token 明明还有效本地判定却已经过期于是不断触发刷新最后把 LWA 配额打满。为保险起见我一般会加一个「距离上次刷新不足 60 秒就不再刷新」的保护条件能有效兜住这类时间异常。从把 Token 交给定时器改成让每个请求自己来决定要不要刷新看起来只是把逻辑挪了个位置实际上改变的是整个故障模型。「过期」从任务里不可回避的崩溃点变成了普通可重试的偶发请求。最后再分享一个体会请求级 Token 这套思路并不只适用于亚马逊广告 API任何短有效期 Token 的第三方接口比如一些短 Token 的云服务、开放平台都可以用同样的缓存加裁决加锁的模式。你把它打磨顺了相当于给未来的 API 对接工作留了一套称手的基建。
返回列表