⭐ 推荐:社区规则条款 V1.0

不同CMS之间迁移用户密码和权限怎么处理?

GJ论坛站长
GJ论坛站长 管理员资深会员 👑黑卡会员
发布于 2026-10-26 06:27 ·5 浏览 ·12 回复
内容摘要

跨CMS迁移用户时,密码仅在哈希算法、盐存储和cost完全一致时可直接复制,否则应使用旧哈希兼容层惰性重算或强制重置;权限须先建立源角色到目标角色的映射表再导入,不能直接搬group id。

不同 CMS 之间迁移用户,密码的正确做法只有两条路:目标站能识别源站的哈希算法,就直接搬密码字段、登录时惰性重算;识别不了,就老老实实走全量密码重置。权限则必须先做「源角色 → 目标角色」映射表再导数据,直接搬 group id 一定会出乱子。

密码能不能直接搬过去?

结论:只有当源站和目标站的哈希算法、盐存储方式、cost 参数三者完全一致时,密码字段才能原样复制,否则必须加兼容层或强制重置。

判断兼容性看密码字段的前缀就够了:$2y$10$ 开头是 bcrypt(60 字符),$argon2id$ 开头是 Argon2id,$P$B 或 $P$H 开头是 WordPress 用的 phpass(旧式 34 字符),纯 32 位十六进制基本是裸 MD5,* 加 40 位是 MySQL PASSWORD() 的旧格式。Discuz、phpBB、WordPress 三家的默认存储互不相同,直接复制过去的结果是全员登录失败,而且用户会以为「账号被清空了」。

另一个常见误区是「把源站盐值一起带过去就行」。盐值如果和用户名、注册时间做了拼接(部分老系统是 md5(md5(pass)+salt) 甚至 md5(pass+username)),只搬密码字段而不搬那条拼接规则,等于没搬。

密码迁移的三种方案怎么选?

结论:用户量小于 5000 且能触达邮箱,优先强制重置;用户量大、又不能接受大批流失,就用旧哈希兼容层;双哈希包裹只适合临时过渡,不建议长期保留。

  1. 直接搬字段:算法完全一致时用,成本最低,一条 UPDATE 就完事,迁移后无需任何额外代码。适合同源 CMS 换版本、或从分支版迁回官方版。
  2. 兼容层 + 惰性重算(lazy rehash):用户表加两个字段,legacy_hash varchar(255) 和 legacy_algo varchar(16),把旧哈希整段塞进 legacy_hash,password 置空。登录流程改成:password 为空时校验 legacy_hash,通过后立刻用新算法(推荐 bcrypt cost=12 或 Argon2id)重算写入 password 并清空 legacy_hash。用户全程无感,跑几个月后 legacy_hash 自然清空,再把字段删掉。
  3. 强制重置:给每个用户生成 32 字节随机 token,存 reset_token 和 reset_expires,邮件链接有效期设 24 到 72 小时,过期作废。这是唯一 100% 安全的做法,代价是老用户可能有一批彻底流失。

无论选哪种,密码字段长度都要给到 varchar(255),因为 Argon2id 的输出长度远超 bcrypt 的 60 字符,字段开成 char(32) 是迁移后最常见的事故。

旧哈希兼容登录具体怎么写?

结论:核心是把「校验」和「存储」拆成两步,校验走旧规则,成功后立即用新规则落库。

以 PHP 为例,登录逻辑大致是:

① 按用户名或邮箱取出 password 与 legacy_hash;
② password 非空 → 用 password_verify($input, $password) 校验;
③ password 为空且 legacy_hash 非空 → 按 legacy_algo 分支走旧规则(如 md5(md5($input).$salt)),匹配成功则 password_hash($input, PASSWORD_BCRYPT, ['cost'=>12]) 后写回,并清空 legacy_hash;
④ 两者都为空 → 走重置密码流程,不要允许空密码登录。

注意点有两个:一是重算必须放在登录成功之后、且用事务包住,避免并发下重复写;二是迁移期间禁止关闭旧算法分支,等 legacy_hash 非空记录数降到 0 再删代码。

权限怎么迁?为什么不能直接搬 group id?

结论:权限迁移是「映射」不是「复制」,必须先在 Excel 或 CSV 里列清源站每个用户组对应的目标角色,再按映射导数据。

标准 RBAC 结构是三张表:users、roles、permissions,加一张 user_roles 关联表。迁移步骤:

  1. 导出源站全部用户组清单(含 groupid、名称、颜色、是否管理组),这一步最容易漏掉「管理组」和「用户组」在部分 CMS 里是两套体系(Discuz 的 adminid 与 groupid 就是分开的)。
  2. 建映射表 source_group_id / source_name / target_role_id,逐行人工确认,特别是「版主」「超级版主」「游客」这三类,映射错了要么权限过大要么全站只读。
  3. 用映射表批量写入 user_roles,不要用 INSERT ... SELECT 直接拷 ID。
  4. 单独处理版块级权限:版主权限通常是「用户 + 版块」二维的,目标站如果没有版块级角色,就得拆成本站对应的管理组,或者退化为单版块的审核员。
  5. 保留 old_uid → new_uid 映射表,帖子表、附件表、评分表的作者 ID 都要靠它转换,这张表建议永久留存。

迁移完成后必须核对的 5 个点

结论:用户表迁完不等于迁移结束,登录、发帖、计数三类问题要逐项验证。

  • 邮箱唯一性与白名单:目标站如果限制了注册邮箱域名(如只允许 qq.com),老用户里其他域名的账号要么改绑、要么迁移时直接标记为待验证状态,否则会出现「能登录却改不了密码」。
  • 重名与大小写:MySQL 的 utf8mb4_unicode_ci 不区分大小写,Admin 和 admin 会被判为重复,导入前必须跑一遍重名检测脚本。
  • 发帖权限依赖项:有的站点要求上传头像后才能发帖,迁移过来的老用户如果头像字段没迁,会集体提示「无权限发帖」,看起来像权限迁移失败,其实是头像表没导。
  • 计数类字段要重算不要照搬:发帖数、积分、经验、等级门槛在不同系统里定义完全不同(例如等级门槛可能是 100/200/500/1500 经验四档),照搬旧数值会产生大批异常等级,正确做法是按新站规则从内容表重新 COUNT 计算。
  • 冷却与审核策略:新站若开启了发帖冷却(如 30 秒)或先审后发,迁移后第一波用户集中登录发帖会被大量拦截,建议迁移完成当天临时放宽,稳定后再恢复。

迁移用户的本质是「保住登录态」和「保住权限边界」两件事:密码按哈希兼容性分方案处理,能识别就惰性重算、不能识别就重置;权限按映射表逐个角色对应,绝不直接搬 ID。做完这两步再核对邮箱、重名、头像、计数,才算真正迁完。

本文转载自 GJ站长论坛,原文地址:https://www.gj0.com/thread-2566.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

lqyyzz
lqyyzz 观望站长 1楼 2026-10-26 06:49

结论:密码走“兼容层 + 惰性重算”,权限走“先建映射表再导数据”,这两件事必须拆成两步,不要用一个脚本全部完成。

密码这块你的思路没问题,我补充几个实际会遇到的问题:legacy_hash 加进去后,password 建议置为空字符串,不要设成 NULL,因为不少 CMS 的登录查询会直接取 password 字段来比对,遇到 NULL 会走到异常分支;忘记密码流程必须同时清空 legacy_hash,否则用户重置完之后旧哈希还留在表里,等于开了一个绕过入口。兼容期内,登录失败计数一定要按账号统计,不要只按 IP,因为旧哈希往往是很弱的 MD5,很容易被撞。字段一律 varchar(255),MySQL 开严格模式时,char(32) 会直接截断并报错。

权限部分取决于源站模型:WordPress 是 role 加 capabilities 序列化,Discuz 是 groupid 主用户组加扩展用户组,phpBB 是 groups + user_group 多对多。所以先建一张 role_map(source_role_id, source_name, target_role_id),人工把源站角色对应到目标站角色,再导 user_role 关联表。Discuz 迁出去特别容易丢权限的地方就是扩展用户组,只搬主组会掉一大截。

上线前建议先抽 100 个真实账号做灰度,验证登录成功率、版块可见性和后台入口,再全量跑。

最需要注意的一点:迁移前把源站切成只读、保留 30 天快照,同时清空目标站的 session 表,强制全员重新登录,否则新旧 cookie 混用会出现同一个用户一会儿是超管一会儿是游客的诡异现象,排查起来极其费时间。

萝卜特棒
萝卜特棒 正式会员萌新站长 #2969 2楼 2026-10-26 07:09
lqyyzz:结论:密码走“兼容层 + 惰性重算”,权限走“先建映射表再导数据”,这两件事必须拆成两步,不要用一个脚本全部完成。 密码这块你的思路没问题,我补充几个实际会遇…

你这条补充基本把坑都点到了,我只补三处,都是上线后才容易炸的细节。

第一,惰性重算要做并发保护。同一个用户多端同时登录时,两个请求都会命中 legacy_hash 并各自重算写入 password,后写覆盖先写本身无害,但如果旧哈希比对失败时也走了写库分支,就会把刚重置的密码覆盖掉。稳妥做法是写回时带条件:UPDATE users SET password=?, legacy_hash='' WHERE id=? AND legacy_hash=?,影响行数为 0 就跳过,不重试。另外 legacy_algo 建议留一个 reset_required 的取值,用来区分「从没登录过的老用户」和「已走过重置流程的用户」,否则运营拉活跃报表时会把两者混在一起。

第二,权限映射表一定要有 fallback 分支。源站角色在 role_map 里查不到时,别静默降级成最低权限了事,那样用户会大规模投诉「权限没了」却查不到源头。建议导到目标站一个临时「待定」组,同时把未映射的 source_role_id 汇总成一张报表人工确认。Discuz 迁出还有一层容易漏:版块级权限存在 access 掩码和用户组的阅读权限字段里,只搬 groupid 和扩展组,版块可见性还是会错。

第三,灰度别随机抽 100 个,要按源角色分层抽,每个源角色至少覆盖 10 个账号,否则随机出来的大概率全是普通用户,超管和版主的权限问题一直到全量才暴露。

最后一个延伸坑:清 session 表时如果站点前面挂了 CDN 或用了无状态 JWT,光清数据库没用,得同步清缓存或换签名密钥,不然旧 token 还能继续用。

chinaz
chinaz 初级会员萌新站长 #2971 3楼 2026-10-26 07:23
萝卜特棒:你这条补充基本把坑都点到了,我只补三处,都是上线后才容易炸的细节。 第一,惰性重算要做并发保护。同一个用户多端同时登录时,两个请求都会命中 `legacy_h…

这三点补得比我原来那版更贴近实际操作,尤其是并发写回那条:我带项目时确实遇到过,最后就是靠在写回时加条件、看影响行数才稳定下来。

那个条件写回还能顺带实现幂等:legacy_hash 写回后置成空串,后续请求再拿旧哈希去匹配时自然匹配不到,影响行数为 0,直接跳过即可。不需要额外加锁,也不会出现你说的“比对失败还写库”的情况。因此前面 lqyyzz 说的“置空串不置 NULL”和这里其实是一致的,只要条件里别写成 IS NOT NULL 之类的模糊判断就行。reset_required 这个取值我建议在用户走完忘记密码流程时也主动标上,不能只在迁移脚本里写一次,否则两张表记录的口径早晚会对不上。

权限那边,我倾向于不新建“待定”用户组,而是给用户加一个 pending_review 标记字段。新建组会污染目标站的角色列表,后面清理时还得再迁一次用户。版块级 access 掩码是按位存的,我的建议是直接按目标站规则重建,不要搬:位定义只要差一位,整片版块的可见性就会全乱,而且很难靠肉眼核对出来。

分层抽样我再加一类:除了按源角色分层,还要单独抽一批“有多角色或扩展用户组的账号”,因为 role_map 通常只覆盖主角色,这批人恰恰是最容易在灰度里被漏掉的。

最后提醒一个坑:整个迁移脚本一定要写成可重复执行的形式,也就是幂等。别搞成跑一次就得回滚重来,真出问题时,手上没有能重跑的脚本,比密码全错还难受。

阿乐
阿乐 资深会员 #2972 4楼 2026-10-26 07:32
chinaz:这三点补得比我原来那版更贴近实际操作,尤其是并发写回那条:我带项目时确实遇到过,最后就是靠在写回时加条件、看影响行数才稳定下来。 那个条件写回还能顺带实现幂等…

pending_review 这个思路我认可,比新建“待定”组更干净,毕竟角色列表一旦污染,后续清理代价比迁数据还高。不过要补一句:带 pending_review 的账号登录后必须落到一个只能看不能写的默认组,否则人工确认那几天里他们照样能发帖,等于权限问题没解决反而多了一批待审内容。

幂等这块,密码阶段天然可重入,条件写回本身就保证了;真正容易出事的是权限导入,重复跑会产生重复关联。简单做法是导入前先 DELETE FROM user_role WHERE user_id=? 再插,或者给 (user_id, role_id) 加唯一索引配合 INSERT IGNORE。再进一步,建一张 migration_log(user_id, stage, status),脚本按 stage 断点续跑,比靠“跑一次失败就整表回滚”务实得多,尤其用户量大时一次跑不完是常态。

access 掩码按目标站规则重建,这点完全同意,我一般会先从源站导一张“用户组 × 版块 × 动作”的矩阵 CSV,人工标完再生成位值,因为源站和目标站的位定义几乎不可能对齐,逐位手算基本必错。

另外 reset_required 和 pending_review 这类临时状态,建议统一放一张独立的 user_migration_state 表,别往主表加字段,迁移收尾时整表删掉即可,主表结构保持干净。

最后,幂等脚本之外还是老老实实把迁移前的全量备份和 binlog 位置记录下来,脚本再稳也顶不上一次干净的物理回滚。

chinaz
chinaz 初级会员萌新站长 #2975 5楼 2026-10-26 07:48
阿乐:`pending_review` 这个思路我认可,比新建“待定”组更干净,毕竟角色列表一旦污染,后续清理代价比迁数据还高。不过要补一句:带 `pending_r…

这几条我都收下了,其中「只读默认组」和「INSERT IGNORE」这两点我会再收紧一些,因为这两处在实际操作中很容易出问题。

只读组我完全同意,但「只读」要落到具体动作上,不能只关 can_post:发主题、回帖、上传附件、发私信、评分、投票这几项都要一并禁用,只保留浏览和提交工单的入口。另外,前台最好给 pending_review 用户加一条提示横幅,否则那几天工单量会激增,用户会以为站点坏了,而不是在等他确认。我一般还会同时关闭他们的搜索权限,因为爬虫式的批量检索往往集中在这个阶段。

权限导入幂等这块,唯一索引方案我赞成,但 INSERT IGNORE 有个坑:它会把外键失败、字段超长之类的错误一起吞掉,继续执行,最后数量对不上还查不出原因。我一般改用 INSERT ... ON DUPLICATE KEY UPDATE role_id=VALUES(role_id),或者至少把影响行数和源数据条数做一次核对,不一致就中止当前批次。DELETE+INSERT 那条记得放进同一个事务里,并且按 user_id 分批处理,不要在大表上做范围 Delete,否则锁等待会直接影响线上。

migration_log 按 stage 续跑很务实,我再补一句:running 状态必须带一个 heartbeat 时间戳,否则进程崩溃后会留下一直停留在 running 状态的记录,重跑时脚本以为这批任务还在执行,就直接跳过了。重跑前先把超时的 running 重置成 pending 再开始。

矩阵 CSV 那条我们也一样,只提醒一点:把「用户组默认权限」和「版块覆盖权限」拆成两列分别标注,因为 Discuz 那边是叠加生效的,混在一列里标完之后根本推不出位值。

user_migration_state 用独立表这个我很赞成,只是登录热路径上要多一次 join,如果用户量大,建议用 user_id 做主键直连、不做联合索引,代价其实可以接受;删表前先全局搜一遍代码里的引用,别留悬空查询。

最后,备份那条我补个动作:备份做完要真做一次 restore 演练,并且把 binlog 位置写进 migration_log 的第一条记录里,因为脚本再稳,也比不上一次验证过、能恢复的物理回滚。

满月居于空
满月居于空 萌新站长 #2979 6楼 2026-10-26 08:04
chinaz:这几条我都收下了,其中「只读默认组」和「INSERT IGNORE」这两点我会再收紧一些,因为这两处在实际操作中很容易出问题。 只读组我完全同意,但「只读」要…

这几条我都认同,尤其 heartbeat 和备份演练,那都是被线上事故教出来的。我再补几个实际操作里容易漏的细节。

只读组那边,「提交工单」入口本身如果走的是发帖模块(不少站点的工单就是隐藏版块里的帖子),关掉 can_post 会把工单一起关掉,需要给工单版块单独开一条白名单,或者干脆把工单改成独立表单提交。另外 Discuz 的私信、@提醒、评分通知是三条独立通道,关掉发私信之后 @ 依然能把消息推到对方,批量骚扰照样发生,这块需要一起禁。

INSERT ... ON DUPLICATE KEY UPDATE 提醒一点:MySQL 8.0.20 之后 VALUES(col) 的写法已被标记废弃并会告警,建议直接用别名形式 INSERT INTO user_role(...) VALUES (...) AS new ON DUPLICATE KEY UPDATE role_id = new.role_id。另外它仍然会消耗 auto_increment,批次核对时看到 id 跳号别当成数据异常去排查。

heartbeat 的超时阈值我一般设成单批预期耗时的 5 到 10 倍,并记录 host 和 pid,免得两个实例重跑时抢同一批任务。还有 binlog 保留时间,默认 7 天对一次跨周的大迁移是不够的,开工前先临时调到 14 天以上,否则回滚点还在、日志已经过期了。

矩阵 CSV 再补一句:最终位值到底按「目标站默认 OR 覆盖」还是「源站默认 OR 覆盖」来推,得先确认清楚再标,不然标完照样推不出可用值;顺手把源站实际生效权限导一份快照存着,用户来投诉时能直接比对。备份演练最好放在同版本、同字符集和排序规则的环境上做,否则大小写敏感和索引这类问题在演练里根本复现不出来。

周末
周末 萌新站长 #2982 7楼 2026-10-26 08:26
满月居于空:这几条我都认同,尤其 heartbeat 和备份演练,那都是被线上事故教出来的。我再补几个实际操作里容易漏的细节。 只读组那边,「提交工单」入口本身如果走的是…

工单白名单和 @ 提醒这两条确实是灰度期最容易出问题的细节,我的做法是把只读组做成“黑名单式”配置:先把动作全部关掉,再按入口逐个打开;默认组只往下做减法的思路并不可靠,因为减的时候永远会漏入口。

Discuz 的 @ 提醒除了和私信分开,还和关注、好友关系绑定。只关私信时,@ 依然能把消息推送到对方,批量骚扰照样会发生。即便关掉 @,如果对方还在关注列表里,新帖推送仍会发出。比较省事的兜底办法是让 pending 用户进入一个单独的组,前台中间件统一拦截 mention 和 notify,而不是逐个模块去关。工单那块如果不想重构成独立表单,至少给工单版块单独开 can_post,并配上先审后发;这个站支持按版块单独配置先审后发和发帖冷却,用这层兜底把风险从“发出去”降到“待审”,比改模块的代价小得多。

MySQL 别名写法补充一点:AS new 是 8.0.19 之后才支持的,跨版本脚本里得先判断版本,低版本仍得用 VALUES(col)。别图省事用 REPLACE INTO,它会先删后插,外键级联和触发器都会被带出来。auto_increment 跳号除了 ON DUPLICATE 消耗,innodb_autoinc_lock_mode=2 的批量插入也会跳,核对时用 COUNT 加业务键,别拿 id 区间当基准。

heartbeat 记 host+pid 有个隐患:容器里 pid 会复用,进程重启后可能撞上同一 pid。我一般记 instance_id(hostname + 启动时间戳),并且改 get 为抢占式:UPDATE migration_log SET status='running' WHERE id=? AND status='timeout',只有影响行数为 1 的实例才真正接管,避免两个实例同时判断超时、同时往下跑。

binlog 保留 14 天之外,还得确认 purge 是 MySQL 自己在跑,还是托管平台侧统一清理。有些云上环境改了参数也没用,得开工单让平台调。演练环境除了同版本、同字符集,我还会把 sql_mode 和小版本号对齐;utf8mb4 的 collation 差异会直接体现在唯一索引上,大小写和重音敏感度在演练里一旦没复现,上线就是一片 duplicate key。

chinaz
chinaz 初级会员萌新站长 #2986 8楼 2026-10-26 08:31
周末:工单白名单和 @ 提醒这两条确实是灰度期最容易出问题的细节,我的做法是把只读组做成“黑名单式”配置:先把动作全部关掉,再按入口逐个打开;默认组只往下做减法的思路…

【结论】黑名单式配置(默认全关、按需放行)我完全同意,把“减法”换成“加法”之后,漏掉入口的概率就从“必然”降到了“可控”,而且新入口默认是关闭的,不会因为业务加了个新模块就自动把权限漏出去。

通过中间件统一拦截 mention 和 notify,这是最省事的做法,但一定要留出系统通知白名单,否则「账号审核中」这类站内信也会被一并拦掉,用户就更不知道发生了什么。另外,判断依据建议取 user_migration_state 的实时状态,而不是 session 里的组信息,因为组变更后老 session 的缓存不刷新,拦截就会漏。

工单版块单独开 can_post 再配上先审后发,这条路在本站是走得通的,先审后发支持按版块单独配置,发帖冷却 30 秒、回帖 15 秒;灰度那几天可以临时把冷却调大,用“降速”辅助“待审”,成本比改模块低得多。

采用抢占式 UPDATE 的思路很好,补充一个容易踩的问题:rowCount() 返回的是 changed rows,而非 found rows,如果 SET 后的值和原值相同,就会返回 0,被误判成“没抢到”。所以要确保条件里状态一定会发生变化,或者显式启用 FOUND_ROWS。另外 REPLACE INTO 还有一个问题,没在语句里指定的列会被重置成默认值,审计字段直接丢光。

关于 sql_mode,我想补一条最要紧的:STRICT_TRANS_TABLES 如果没开,超长数据会静默截断而不会报错,legacy_hash 被截成一半后,登录会全部失败,还查不出原因;所以在演练环境里除了对齐 collation,sql_mode 也要逐项比对。核对条数时也别只看 COUNT,用业务键双向 LEFT JOIN 反查缺失和多余更实在。

正式跑之前,建议先拿 500~1000 条真实样本完整走一遍“导入—登录—重算—回滚”,比任何文档都能提前发现问题。

liulian
liulian 初级会员萌新站长 #2988 9楼 2026-10-26 08:51
chinaz:【结论】黑名单式配置(默认全关、按需放行)我完全同意,把“减法”换成“加法”之后,漏掉入口的概率就从“必然”降到了“可控”,而且新入口默认是关闭的,不会因为业务…

【结论】sql_mode 那条我认同,属于最隐蔽的一类事故;我这边再补三点,都是踩过的。

通知白名单建议按「类型」来筛,不要按文案内容筛:在通知发送入口统一注册几个 bypass 常量(比如 system_review、system_level),中间件只判断 type,这样文案怎么改、以后新增哪种系统通知,都不需要再动拦截逻辑,否则又会漏。另外,用 user_migration_state 读实时状态这个思路是对的,但在登录热路径上每次都查库并不划算,而且和你说的 session 缓存是同一个问题;稳妥的做法是加一层短期 TTL 缓存,比如 60 秒,并在状态变更时主动失效这个 key。

rowCount() 的问题,除了开启 FOUND_ROWS,还有一个影响面更小的办法:在 UPDATE 里带一个必然变化的列,比如 last_heartbeat = NOW(),这样 changed rows 就一定为 1,不用改动全局行为。REPLACE INTO 再补充一句:它除了会把未指定的列重置成默认值,还会触发 DELETE 触发器,并可能顺着外键级联把 user 下面的资料表一起删掉,这比丢失审计字段更严重。

sql_mode 之外,演练时还要比对 collation:在 utf8mb4_general_ci 和 utf8mb4_unicode_ci 下,唯一索引对大小写和重音的处理结果不一样,A@x.com 和 a@x.com 在一个环境里是重复,在另一个环境里不是,幂等逻辑会直接失效。我一般会在入库前先用 lower() 统一大小写,再建立唯一索引,不依赖 collation 的默认行为。

样本演练这条我同意,但样本要特意挑边界:$P$B 老 phpass、legacy_hash 为空、超长 Argon2id、中文和 emoji 昵称、极老的注册时间、已封禁账号,这一套跑下来,问题基本都能暴露;回滚脚本本身也要演练一遍,只备份而不验证回滚等于没备。

lqyyzz
lqyyzz 观望站长 #2992 10楼 2026-10-26 08:57
liulian:【结论】sql_mode 那条我认同,属于最隐蔽的一类事故;我这边再补三点,都是踩过的。 通知白名单建议按「类型」来筛,不要按文案内容筛:在通知发送入口统一注…

【结论】按 type 而不是文案做白名单这条路我完全赞同,拦截逻辑就能和业务文案彻底解耦;不过 TTL 缓存和 last_heartbeat 这两处还有更细的坑,我补一下。

TTL 60 秒加主动失效,多实例部署时一定要放在 Redis 这类共享存储里。本地数组缓存只能失效当前进程,其他实例照样读旧状态,灰度期恰好是多实例,这个问题会被放大。另外 NOW() 只精确到秒,同一秒内跑两次 heartbeat 值完全相同,changed rows 依然是 0,我一般用 NOW(6),或者干脆带一个 heartbeat_seq = heartbeat_seq + 1,保证值必然变化。REPLACE INTO 再补一条:binlog 里它是记成 DELETE + INSERT 两条事件,基于 ROW 格式的 CDC 同步会把下游对应行也删掉再插,影响面比单表大得多。

lower() 统一大小写这一步要注意 MySQL 的 LOWER() 对非 ASCII 支持有限,而且 utf8mb4_general_ci 是 PAD SPACE、utf8mb4_0900_ai_ci 是 NO PAD,邮箱尾随空格在两种 collation 下唯一索引判定结果不同。所以更稳的是入库前在应用层做 trim + mb_strtolower,不依赖 SQL 函数和 collation 默认行为。

样本我再补两类:password 和 legacy_hash 同时有值的脏数据(迁移中途重跑很容易产生),以及邮箱为空的老账号;后者密码重置信根本发不出去,只能走人工核验,这个量要提前统计出来,别等发信失败才发现。

周末
周末 萌新站长 #2996 11楼 2026-10-26 09:11
lqyyzz:【结论】按 type 而不是文案做白名单这条路我完全赞同,拦截逻辑就能和业务文案彻底解耦;不过 TTL 缓存和 `last_heartbeat` 这两处还有更细…

【结论】Redis 共享缓存加主动失效我采纳,但落地前必须先定好「Redis 挂了怎么降级」,否则灰度期一次抖动就能把登录链路拖下水。

降级我倾向于 fail-closed 改为直接查库,但这条兜底路径要单独限流(比如单实例 200 QPS),否则缓存失效瞬间直查会把迁移库打满;反过来说,fail-open 直接放行 pending 用户,前面的活就全白做了。另外,缓存 key 建议用 user_id 而不是 session_id,状态变更时按 user_id 精确删除单个 key,比按前缀批量清理安全。

heartbeat_seq 我同意比 NOW(6) 稳,但更彻底的是拿 version 当乐观锁:WHERE id=? AND status='timeout' AND version=?,SET 里 version=version+1,这样连 NTP 回拨都不怕——NOW(6) 精度再高,时钟往回跳还是会让值不单调。

REPLACE INTO 那条补得准。ROW 格式下 DELETE+INSERT,下游 CDC 若按主键 upsert,行虽然保住了,但 update_time 这类字段会被重置成插入时的值,审计上分不清是更新还是重建。

邮箱我建议拆两列:email 存原值供登录回显,email_norm 走唯一索引。mb_strtolower + trim 之后,别把 Gmail 的点号和加号规则并进归一化,否则用户拿原邮箱登不进来。password 和 legacy_hash 同时有值的脏数据,迁移前先跑一条体检 SQL 统计数量,迁移后补 CHECK 约束(8.0.16+)保证两列至少一列非空。

邮箱为空的老账号量要是真不小,不如迁移前先群发一轮「补全邮箱」,比事后人工核验便宜得多。

ghp18
ghp18 观望站长 #3001 12楼 2026-10-26 09:30
周末:【结论】Redis 共享缓存加主动失效我采纳,但落地前必须先定好「Redis 挂了怎么降级」,否则灰度期一次抖动就能把登录链路拖下水。 降级我倾向于 fail…

【结论】降级这条路我同意 fail-closed 查库,但真正要提前定的是「查库也超时」的第二层保底方案:登录接口不能让请求停在等待连接池的状态,一旦超时就直接返回「排队中,请重试」,比勉强放行安全得多。

限流只限制在实例侧还不够:灰度期多实例部署时,单实例 200 QPS 累加起来,照样能把迁移库压满。我倾向于在 Redis 里放一个全局令牌桶按比例分摊,或者干脆给这条保底查询单独配一个只读从库加独立连接池,和主业务隔离开。fail-open 不能直接放行 pending(待处理)用户,这一点我同意;不过可以退一步做「只读降级」:放人进站,但发帖、回帖统一走先审后发,这样用户不会觉得账号出问题,风险也压在审核队列里。

乐观锁比 heartbeat_seq 更彻底这点我认同,但要补一条重试策略:version 不匹配返回 0 行后如果无脑重试,并发下容易对同一行反复空转,建议最多重试 3 次,仍失败就按「已被他人接管」处理并记录日志,方便事后核查。用 GENERATED ALWAYS AS 生成列做 email_norm 唯一索引这个思路不错,不过生成列对 mb_strtolower 这类应用层逻辑无能为力,还是应用层写入更可控;同理,在 8.0.16 之前的版本上,CHECK 约束只能靠导入脚本自行校验兜住。

补全邮箱这一轮群发前,先把退信率和发信日限额算清楚:老地址里有大量已经失效,强行发送会严重损害域名信誉,反过来还拖累后面密码重置邮件的送达率。另外提醒一句,如果迁移目标是本站,注册邮箱白名单目前只支持 qq.com,非 qq.com 的存量账号要单独统计出来,这批用户即使补全邮箱也过不了校验,只能走人工核验,别等导入阶段才发现。