后端注册接口实战:契约设计、密码存储与唯一索引兜底
1. 先钉死接口契约,再动手写代码
写注册接口这件事,很多人第一反应是打开 IDE 建个 Controller,然后一边写一边想字段。我见过太多项目在这个环节翻车:前端调了两天发现字段对不上,测试提了十几个"为什么这里返回 500",上线后运维发现日志里全是空指针。真正省时间的做法恰好相反——先把契约写在纸上,包括请求字段、返回结构、错误码、幂等规则,再开始写第一行业务代码。注册接口看着简单,它却是整个账号体系的入口,字段定义一旦定下来,后面改一次要牵动登录、找回密码、第三方绑定、用户中心一大串模块。
这篇文章讲的是「后端系统开发之——创建注册接口」,我会把它当成一次完整的实战复盘来做,从契约设计、密码存储、表结构与唯一约束、事务与并发、频控加固,一直讲到联调自测。适合刚接触后端业务开发、准备写第一个正式接口的朋友,也适合做了一两年但一直靠"能跑就行"撑着的同行——里面有不少地方,是踩过坑之后才知道该这么写的。
1.1 一个注册请求到底该收哪些字段
先把字段分成三类:身份凭证、可选资料、上下文信息。身份凭证是必须的,通常是手机号或邮箱二选一,加一个密码;可选资料是昵称、头像、邀请码这类,缺了也能创建账号;上下文信息是客户端类型、渠道来源、设备标识,用来做风控和后期分析,一般不参与业务校验。
分清楚这三类,好处是校验逻辑可以分层:身份凭证缺失直接 400,可选资料缺失走默认值,上下文信息缺失只记一条告警不阻断流程。我见过把邀请码做成必填的接口,注册转化率直接掉了三成,后来才发现是运营在某次活动页跳转时没带参数。
下面这份请求体是我目前项目里用的最小集合,可以直接参考:
{ "account": "13800000000", "password": "Abc@123456", "nickname": "路人甲", "channel": "web", "inviteCode": "XK92A", "captchaToken": "c_8f3a..." }几个细节值得说清楚。account用一个字段同时承载手机号和邮箱,靠正则区分类型,好处是前端只渲染一个输入框,缺点是后端每次都要做类型判断;如果你的业务明确只支持手机号,那就老老实实叫mobile,别为了"以后可能支持邮箱"提前抽象,抽象是有成本的。password只做长度和字符集校验,绝不在请求体里做业务语义上的复杂度提示,那属于前端体验层的事,后端只守住底线。
captchaToken这类字段建议单独抽出来,别和业务字段混在一起,因为它对应的校验逻辑是"一次性消耗",和幂等、事务的边界完全不同。
1.2 返回体与错误码,别让前端靠猜
返回结构我坚持用统一信封:
{ "code": 0, "message": "ok", "data": { "userId": 10231, "token": "eyJ..." }, "traceId": "a1b2c3d4" }code为 0 表示成功,非 0 是业务错误码,HTTP 状态码只用来表达传输层语义(200/400/401/500),不要用 HTTP 状态码承载业务错误,否则前端要写一大堆if (status === 409)的分支,网关层的统一重试策略也会被误导。traceId是我强烈建议加上的字段,用户截图报错时,你拿着 traceId 能直接定位到那一条完整日志链路,排查效率的提升是数量级的。
错误码要给区间规划,别随手编号:
| 区间 | 含义 | 示例 |
|---|---|---|
| 0 | 成功 | 0 |
| 1000-1099 | 参数与格式错误 | 1001 账号格式不正确 |
| 1100-1199 | 业务规则拦截 | 1101 该账号已注册 |
| 1200-1299 | 频控与风控 | 1201 操作过于频繁 |
| 1500-1599 | 系统内部异常 | 1500 服务繁忙,请稍后重试 |
规划好区间,前端可以按百位做统一提示文案,后端加新错误码也不至于撞号。
1.3 三个最容易被忽略的隐含需求
第一是幂等。用户手抖点两下、网络超时自动重试、客户端做了一次重发,这些场景在移动端是常态。注册接口如果不做幂等,结果就是数据库里出现两条同号账号,或者第二条请求报"已注册"而用户以为自己没注册成功。
第二是来源校验。开放公网的注册接口一定会被扫,加一个来源白名单或者签名校验,能挡掉相当一部分低级脚本。
第三是注册成功后的返回内容。要不要直接返回登录 token?我的做法是返回,理由是省一次登录往返,用户体验上顺滑很多;代价是注册接口要依赖签发 token 的模块,耦合度略高。如果团队对登录态有更严格的要求(比如必须二次校验设备指纹),那就只返回 userId,让前端跳登录页。
提示:契约定稿后写成一份接口文档并纳入版本管理,字段增删要走变更流程。我吃过一次亏,某同学为了赶需求悄悄把
nickname改成必填,结果老版本 App 全量报错。
2. 密码存储的取舍:算法、参数与那条 72 字节的暗线
密码这块是最不该省事的地方,也是最容易埋雷的地方。我见过用 MD5 存的、用 MD5 加固定盐存的、用 Base64 当加密的,每一种都能在出事后把团队拖进深坑。
2.1 为什么自己拼 MD5 加盐基本等于没加
哈希算法的核心指标是计算速度。MD5 和 SHA-256 是为"快"设计的,一张消费级显卡每秒能算几十亿次。这意味着攻击者拿到库之后,用常见密码字典跑一遍,弱密码半小时内就能还原出明文。加盐确实能挡住彩虹表,但挡不住针对单个账号的暴力枚举——因为快。
真正适合存密码的是慢哈希:BCrypt、scrypt、Argon2 这一类,设计目标就是"故意算得慢",而且慢的程度可以通过参数调节。它们内置了每个用户独立的盐,输出串里自带算法标识、成本参数和盐值,不需要你额外建字段存盐。这一点特别重要:自己管理盐的代码,十个有八个在某个分支里写错。
2.2 成本参数到底该设多少
BCrypt 的成本参数是cost,每加 1,计算耗时翻一倍。常用的cost=10,在普通云主机上单次哈希大约 50-100ms。这个数值不是拍脑袋定的,要结合你的硬件和流量算一笔账。
估算逻辑是这样的:假设注册加登录合计峰值 300 QPS,每次哈希按 80ms 计,那么同时处于哈希计算中的请求数为 300 × 0.08 = 24。这部分是纯 CPU 密集操作,一个 4 核机器基本扛不住,你会看到 CPU 打满、请求排队、P99 飙到几秒。反过来说,如果你现在只有 20 QPS,那cost=12完全没问题,安全性还更高。
我给的经验区间是:cost=10起步,实测单次耗时控制在 100ms 以内,压测确认 CPU 余量大于 50%。真要更高强度,就上 Argon2id 并调内存参数,或者把哈希计算挪到独立的线程池/服务里,别和业务线程抢资源。
还有个参数叫strength(BCryptPasswordEncoder 里的版本),默认 10,注意它和cost是同一个东西的两种叫法,别在配置里写成两个值。
2.3 BCrypt 只认前 72 字节,这个坑很隐蔽
这是我最想强调的一条。BCrypt 的算法设计只处理输入的前 72 个字节,超出部分被静默截断。也就是说,如果你允许用户设置超长密码,第 73 个字节之后的内容完全不参与计算,两个前 72 字节相同的密码会被判定为同一个密码。
更麻烦的是中文密码——一个汉字在 UTF-8 下占 3 个字节,72 字节大约只够 24 个汉字。有些库在超长时会直接抛异常(Spring Security 的某些版本就会),有些库则默默截断,行为不一致。所以我的做法是:在参数校验层就把密码长度卡死在 8 到 64 个字符,并且明确按字节数再校验一次不超过 72 字节。
private static void checkPassword(String pwd) { if (pwd == null || pwd.length() < 8 || pwd.length() > 64) { throw new BizException(1002, "密码长度需在 8-64 个字符之间"); } if (pwd.getBytes(StandardCharsets.UTF_8).length > 72) { throw new BizException(1002, "密码过于复杂,请适当缩短"); } }另外补一句:pepper(全局密钥)不是必须的,但如果你担心库被拖走后成本参数不够用,可以在哈希前拼一段存在配置中心或环境变量里的密钥。代价是密钥轮换时所有存量密码都要重算,所以一般只在新项目里上,老项目别折腾。
3. 表结构与唯一约束:把并发问题交给数据库
写业务代码的人常有个错觉,觉得"我在代码里查过了没重复,那就不会重复"。单线程下这话没错,多线程和分布式环境下,这行代码等于没写。注册接口的重复账号问题,最可靠的兜底方案永远是数据库的唯一索引。
3.1 用户表的最小字段集
先上一份我认为比较克制、但足够支撑后续扩展的建表语句:
CREATE TABLE `t_user` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `account` VARCHAR(64) NOT NULL COMMENT '账号(手机号或邮箱,已归一化)', `account_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1手机号 2邮箱', `password_hash` VARCHAR(100) NOT NULL, `nickname` VARCHAR(32) NOT NULL DEFAULT '', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 2禁用', `register_channel` VARCHAR(16) NOT NULL DEFAULT '', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `deleted_at` BIGINT NOT NULL DEFAULT 0 COMMENT '0 未删除,否则为删除时间戳', PRIMARY KEY (`id`), UNIQUE KEY `uk_account` (`account`, `deleted_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个设计理由:password_hash给 100 长度是因为 BCrypt 输出固定 60 字符,Argon2 可能更长,留余量避免以后改表。status不要用布尔值,业务早晚会有"未激活""已冻结""已注销"这几种状态。时间字段统一用DATETIME并存 UTC,别混用时间戳和字符串。
3.2 软删除和唯一索引天生打架,怎么解
如果你直接UNIQUE KEY (account),那用户注销后想重新注册同号就废了——因为那条软删除的记录还占着位置。常见解法有三种:
第一种是上面用的(account, deleted_at)组合唯一索引,删除时把deleted_at写成删除时刻的时间戳。因为未删除记录都是 0,(account, 0)只能存在一条,符合要求;删除后变成(account, 1723...),同号可以再注册一条。这个方案唯一的坑是同一账号被删两次会撞索引,所以删除逻辑里要先判断deleted_at = 0。
第二种是物理删除或者把账号改写(比如加后缀),实现简单但丢失审计能力,我不太推荐。
第三种是单独建一张注销账号表做归档,主表物理删除。适合合规要求高的场景。
3.3 归一化不做,唯一索引就是个摆设
这是我排查过最气人的一类问题:库里出现了13800000000、138 0000 0000、13800000000(尾部空格)三条记录,唯一索引一条都没拦住,因为它们在字符串层面确实不相等。
所以在进入业务逻辑之前,必须做一次归一化:手机号去掉所有空格、横线、括号和+86前缀;邮箱整体转小写(严格说只有域名部分大小写不敏感,但实践中整体小写更省事,且绝大多数服务商不区分);账号两端trim。
public static String normalize(String raw, int type) { if (raw == null) return null; String s = raw.trim(); if (type == 1) { s = s.replaceAll("[\\s\\-()]", ""); if (s.startsWith("+86")) s = s.substring(3); if (s.startsWith("0086")) s = s.substring(4); if (s.length() == 13 && s.startsWith("86")) s = s.substring(2); } else { s = s.toLowerCase(Locale.ROOT); } return s; }注意toLowerCase一定要带Locale.ROOT,不带的话在土耳其语环境下I会被转成ı,这是个真实存在过的经典 bug。
4. 接口实现骨架:校验、事务、异常三段切
骨架理清楚之后,代码写起来其实很快。我的分层习惯是把 Controller 做薄、Service 做业务编排、仓储层只碰数据,异常统一在一处兜。
4.1 参数校验分两层,别全塞进注解
第一层是格式校验,用 Bean Validation 注解就能覆盖:非空、长度、正则。这一层的好处是失败信息标准,前端好处理。
public class RegisterRequest { @NotBlank(message = "账号不能为空") @Size(max = 64, message = "账号过长") private String account; @NotBlank(message = "密码不能为空") private String password; @Size(max = 32, message = "昵称过长") private String nickname; private String channel; private String inviteCode; private String captchaToken; }第二层是业务校验,放 Service 里做:账号格式是否符合手机号/邮箱规则、密码强度是否达标、验证码是否正确、账号是否已存在。为什么不在注解里做完?因为业务校验往往要查库、要调缓存、要读配置,放在参数绑定阶段会让事务和依赖注入变得很别扭。
4.2 事务边界要包住"插入 + 初始化"
注册不只是插一条用户记录,通常还要初始化用户资料、发积分、写注册流水、绑定渠道关系。这些动作要么全成功,要么全回滚,所以事务边界必须包住它们。
@Transactional(rollbackFor = Exception.class) public RegisterResult register(RegisterRequest req) { String account = AccountUtil.normalize(req.getAccount(), type); checkPassword(req.getPassword()); if (userRepo.exists(account)) { throw new BizException(1101, "该账号已注册"); } User user = new User(); user.setAccount(account); user.setPasswordHash(passwordEncoder.encode(req.getPassword())); user.setNickname(defaultNickname(req.getNickname())); user.setRegisterChannel(req.getChannel()); try { userRepo.insert(user); } catch (DuplicateKeyException e) { throw new BizException(1101, "该账号已注册"); } profileService.init(user.getId()); pointService.grantRegisterPoint(user.getId()); return new RegisterResult(user.getId(), tokenService.issue(user.getId())); }注意try-catch只包住 insert,别把整个方法体包进去,否则后续任何一处抛DuplicateKeyException(比如 profile 表自己的唯一约束冲突)都会被误判成"账号已注册",排查起来非常痛苦。
4.3 那个 exists 查询到底要不要
上面的代码里我先查了一次exists,然后插入时又靠唯一索引兜底。有人会问:既然唯一索引能兜,查这一次不是浪费?
我的判断是要查,但要知道它只是为了体验。查询的作用是让 99% 的重复注册请求走快速失败路径,返回一个明确的"已注册",而不是让它们都触发一次数据库异常。异常路径的成本高得多——要构造异常对象、要回滚事务、日志里还会留一堆堆栈。所以这个查询是优化,不是防线;防线永远是唯一索引。
如果你的接口 QPS 很高,这次查询可以改成查缓存,但要接受缓存和数据库之间的短暂不一致,最终仍以唯一索引为准。
4.4 异常统一处理,别把堆栈吐给前端
用@RestControllerAdvice收口所有异常,业务异常转成对应的 code,未捕获异常统一转 1500 并打日志(带上 traceId、入参脱敏后内容)。
@ExceptionHandler(BizException.class) public Result<?> handleBiz(BizException e) { return Result.fail(e.getCode(), e.getMessage()); } @ExceptionHandler(Exception.class) public Result<?> handleOther(Exception e) { log.error("register error traceId={}", TraceContext.get(), e); return Result.fail(1500, "服务繁忙,请稍后重试"); }入参日志一定要脱敏。密码、验证码这些字段在打印请求体前要替换成***,我见过某次排查问题直接把日志拖出来发群里,里面几百条明文密码的场面。
5. 一次"重复账号"的完整排查链路
理论讲完,说个真实复盘(细节做了模糊处理)。某次上线后第二天,运营反馈有用户在后台看到两个一模一样的手机号,只是 id 不同。这条线索足够把整个链路翻一遍。
5.1 现象确认:到底是数据重复,还是展示重复
第一步不是改代码,是确认现象。我拉了三样东西:那两条记录的完整字段、对应时间段的接口访问日志、以及用户的操作轨迹。
结果很快出来:两条记录创建时间相差 1.2 秒,created_at精确到秒级别的差距,register_channel一条是web一条是h5。这说明是同一个人在两个端先后点了注册,而不是数据同步问题。
5.2 逐层排除:为什么唯一索引没拦住
接下来就是最关键的一步——查索引。执行SHOW CREATE TABLE t_user,发现线上表根本没有uk_account这个索引。追问之后才知道,当初建表脚本里有,但某次加字段的变更脚本是照着旧版本建的,把唯一索引漏掉了,DDL 执行时也没人核对。
这里我要强调排查思路:遇到"本该被约束住却没约束住"的问题,第一顺位永远是确认约束是否存在,第二顺位才是确认约束是否生效(比如字段是否被隐式转换、是否有 NULL)。别一上来就怀疑框架、怀疑事务隔离级别,那是最费时间的方向。
顺便查了下SHOW INDEX FROM t_user,确认只有主键。问题定位完成。
5.3 修复与验证,以及怎么防止再犯
修复分三步走:先补索引(建之前要先清理存量重复数据,否则 DDL 直接失败);再加应用层的兜底捕获;最后把这次事故变成一个可执行的检查项。
清理重复数据的 SQL 大致是这样,保留每组最早的一条:
DELETE t1 FROM t_user t1 INNER JOIN t_user t2 ON t1.account = t2.account AND t1.id > t2.id WHERE t1.deleted_at = 0 AND t2.deleted_at = 0;加索引用ALTER TABLE t_user ADD UNIQUE KEY uk_account (account, deleted_at),注意大表要评估锁表风险,必要时用在线 DDL 工具在低峰期执行。
验证阶段我写了一个并发测试脚本:开 50 个线程同时用同一个手机号注册,期望结果是 1 条成功、49 条返回 1101。修复前这个用例必然失败,修复后必须 100% 通过。这个脚本后来被我放进了回归用例集,每次改动注册相关代码都跑一遍。
至于防止再犯,我做的事很朴素:把"关键索引清单"写进发布检查表,每次涉及表结构变更,发布前必须对照清单核验一遍。
| 检查项 | 验证方式 | 频率 |
|---|---|---|
| 账号唯一索引存在 | SHOW INDEX FROM t_user | 每次表变更 |
| 索引列与归一化逻辑一致 | 代码 review | 每次改动 |
| 并发重复注册用例通过 | 自动化脚本 | 每次发布 |
| 存量重复数据为 0 | 定时巡检 SQL | 每天 |
6. 上线前的加固:频控、验证码与那些看不见的成本
接口功能跑通只是及格线,真正决定它能不能放在公网上的,是防护做得够不够。
6.1 频控放在网关还是业务层
我的答案是两层都放,粒度不同。网关层做粗粒度限流,比如单 IP 每分钟 60 次,目的是挡住明显的脚本扫描,成本极低,配置几行就完事。业务层做细粒度控制,比如"同一手机号 60 秒内只能发一次验证码""同一设备指纹 24 小时内最多注册 3 个账号",这类规则需要读业务数据,放网关不合适。
业务层频控用 Redis 的滑动窗口或者简单的SET key value NX EX就够了,别一上来就上复杂组件。关键在于 key 的设计要能区分维度:reg:mobile:{account}、reg:ip:{ip}、reg:device:{deviceId},三个维度任一超限就拦。
6.2 注册接口要不要提示"该账号已注册"
这是个产品和技术要一起决策的点。从安全角度,直接提示"已注册"意味着接口可以被用来枚举哪些手机号在你们平台注册过,这是实打实的信息泄露。
但从业务角度,注册页不提示的话用户体验极差——用户输完密码点提交,得到一句"操作失败",然后呢?
我的实践结论是:注册场景直接提示,登录和找回密码场景模糊处理。理由是注册页本身就要发验证码,验证码环节已经把枚举成本抬得很高了(每次枚举都要消耗一条短信);而登录接口如果提示"密码错误"而不是"账号不存在",攻击者就无法批量判断账号是否存在。这个取舍没有标准答案,但要明确写进设计文档,别让后人以为你忘了。
6.3 注册成功后的异步动作
欢迎消息、初始化推荐数据、发券、埋点上报,这些动作都不该阻塞注册响应。我的做法是发一条领域事件,异步消费。但这里有个坑:异步动作失败要不要回滚注册?答案通常是不要。注册是主流程,发券失败可以补偿,不能因为营销系统挂了让用户注册不了。
所以异步消费必须做重试和死信处理,并且要有对账机制——每天跑一次"注册用户数 vs 发券记录数",差得多就报警。
7. 自测清单:让接口在提测前就接近可用
我带的项目里,注册接口提测前必须自己跑完一份清单,这份清单帮我省下了大量来回沟通。
7.1 边界用例,按这个顺序过一遍
| 用例 | 期望结果 |
|---|---|
| 正常手机号 + 合规密码 | 成功,返回 userId 和 token |
| 手机号带空格、带 +86 | 归一化后成功 |
| 邮箱大小写混写 | 归一化后可登录 |
| 密码 7 位 / 65 位 | 返回 1002 |
| 密码 64 个汉字(192 字节) | 返回 1002,不得静默截断 |
| 已注册账号重复提交 | 返回 1101 |
| 同账号并发 50 次 | 1 成功 49 失败 |
| 验证码错误 / 过期 / 重复使用 | 分别返回对应错误码 |
| 超频提交 | 返回 1201 |
| 数据库连接断开 | 返回 1500,日志有 traceId |
最后一条特别容易被忽略。我习惯在测试环境手动停一下数据库连接池,看接口返回的是不是友好的 1500,而不是一个 500 加一段堆栈。这个动作花不了一分钟,但能避免上线后前端拿到看不懂的响应。
7.2 压测看什么指标
注册接口的压测重点不是吞吐,而是哈希计算对 CPU 的影响。我会关注三个数:单次请求耗时中位数、P99、以及压测期间机器的 CPU 使用率曲线。如果 P99 是中位数的 5 倍以上,基本可以断定是哈希线程池排队或者数据库连接池不够,而不是业务逻辑慢。
另外要观察 Redis 频控 key 的增长速度,如果发现某个 IP 维度产生了海量 key,说明限流阈值设得太松,需要收紧。
最后分享一个我在实际操作中的习惯:注册接口的每一次改动,我都会把上面那份清单里最靠前的三条和并发那条重新跑一遍,不管改动多小。这四条覆盖了归一化、校验、唯一约束和幂等四个最容易出问题的地方,跑完心里就踏实了。