外部账号创建流水号并发问题分析
1. 文档目的
记录运营支撑域批量创建外部账号时出现“一条成功、一条失败”的问题,包括当前实现、现场日志、问题原因、已提出的疑问,以及领导提出的“1 秒除以 RT、AtomicInteger、Redis”三个思路,作为后续方案评审和改造依据。
2. 问题现象
创建李金海账号时,请求参数如下:
{
"branchOrgCode": "224400080000",
"displayName": "李金海",
"externalClassification": "HZ02",
"externalHead": "4400220142",
"externalId": "2094974754335350785",
"externalSource": "M010017",
"joinCompDt": "2026-09-02",
"leaveCompDt": "2027-09-02",
"phoneNo": "15360423589"
}接口返回:
{
"digestLog": "-",
"digetValues": [],
"message": {
"code": "INVALID_OPERATION",
"message": "创建失败,请重试"
},
"needRetry": false,
"pageIndex": 0,
"pageSize": 0,
"status": 2
}同一批处理中,另一条外部账号创建成功并返回账号:
zhex02888801582现场表现为“一条成功、一条失败”。虽然调用方认为账号是逐个处理的,但服务端日志显示两个 RPC 实际发生了重叠执行。
3. 当前实现
3.1 外部账号创建流程
入口执行器为 ExternalAccountAddCmdExe,主要流程如下:
- 校验入司日期和离司日期。
- 校验手机号是否已被在职员工使用。
- 校验
externalId + externalSource是否唯一。 - 校验外部来源是否具备接口创建权限。
- 校验外部负责人
externalHead。 - 校验外部业务分类
externalClassification。 - 校验自定义编码
customCode。 - 生成外部账号
accountId。 - 创建内部账户。
- 创建 IDaaS 账户。
- 建立关联账户关系。
- 发送账户变更 MQ。
本次错误出现在第 8 步“生成外部账号 accountId”,因此前面的日期、外部来源、负责人和业务分类等校验已经通过,不是这些入参直接导致的错误。
3.2 账号编码组成
外部账号格式为:
zhex + 2 位应用简称 + 4 位自定义编码 + 5 位流水号本次请求没有传入 customCode,系统从应用路由中读取默认自定义编码。当前现场配置为:
| 字段 | 值 |
|---|---|
short_id | 02 |
customize_code | 8888 |
序列表主键 id | 4 |
is_valid | 1 |
tenant_code | cic |
例如流水号为 1582 时,生成的账号为:
zhex028888015823.3 当前流水号生成方式
AppSequenceGatewayImpl.externalId() 使用独立事务 REQUIRES_NEW 生成流水号:
- 根据
externalSource=M010017查询应用路由。 - 得到
short_id=02和默认customize_code=8888。 - 查询
t_abs_app_sequence当前seq_no。 - 使用主键和旧流水号进行条件更新:
UPDATE t_abs_app_sequence
SET seq_no = :oldSeqNo + 1
WHERE id = :id
AND seq_no = :oldSeqNo;这是一种乐观锁/CAS 思路:只有数据库中的流水号仍等于刚才读到的旧值,更新才会成功。
当前代码在更新条数为 0 时不进行内部重试,而是直接抛出:
INVALID_OPERATION:创建失败,请重试因此当前实现能够检测流水号竞争,但不能自动消化竞争,失败会直接暴露给调用方。
另外,流水号生成使用 REQUIRES_NEW 独立事务。即使后续内部账号或 IDaaS 创建失败,已经成功递增的流水号一般也不会随外层事务回滚,因此可能出现断号。账号流水号通常只要求唯一,不要求绝对连续,断号是否可以接受需要按业务要求确认。
4. 并发证据
4.1 两次调用的日志
两条请求使用相同的 TraceId:
0aced0b01788315878390884326Span 分别为:
0.1.1.1.7
0.1.1.1.9请求被分发到了两个不同的 Pod:
| 请求结果 | 容器 IP | Pod | 日志结束时间 | RT |
|---|---|---|---|---|
| 成功 | 10.206.215.44 | midaboss-sso-cell-gz00a-vmht5-nxgrj | 10:24:39.075 | 210ms |
| 失败 | 10.206.206.117 | midaboss-sso-cell-gz00a-vmht5-xzks6 | 10:24:38.878 | 13ms |
4.2 根据 RT 还原开始时间
请求开始时间可以通过下面的公式推算:
开始时间 = 日志结束时间 - RT计算结果:
成功请求:10:24:39.075 - 210ms = 10:24:38.865
失败请求:10:24:38.878 - 13ms = 10:24:38.865两次请求在同一毫秒开始,并由两个不同 Pod 执行。因此,尽管调用方代码或业务理解是“一个个顺序处理”,从服务端实际执行情况看,两次 RPC 是并发请求。
相同 TraceId、不同 Span 还表明两次 RPC 来自同一个上游调用链。后续应重点检查上游是否使用并行流、线程池、CompletableFuture、异步消息消费,或者在没有等待前一个 RPC 返回时就发送了下一次请求。
说明:日志平台外层显示的采集/接收时间可能晚于业务日志时间,判断调用先后应使用 content 中的业务时间和“耗时”,不能使用日志采集时间。
5. 问题产生的原因
5.1 直接原因
两个 Pod 很可能先后读取到了同一个旧流水号,例如 1581:
Pod A:读取 seq_no=1581
Pod B:读取 seq_no=1581Pod A 先完成条件更新:
WHERE id=4 AND seq_no=1581
更新成功,数据库变成 seq_no=1582Pod B 再使用旧值更新:
WHERE id=4 AND seq_no=1581
数据库已经是 seq_no=1582,更新条数为 0于是 Pod A 继续完成账号创建并返回 zhex02888801582,Pod B 在 13ms 内快速返回“创建失败,请重试”。这与现场“一条成功、一条失败”的表现一致。
上述流水号值是依据成功账号后缀和当前代码还原的高概率过程。要形成数据库级最终证据,还应查看同一 TraceId 下两次 SELECT 和 UPDATE 的实际 SQL 参数及影响行数。
5.2 根本原因
根本原因包括两层:
- 上游实际并发发送了两个外部账号创建请求,且请求被负载均衡到不同 Pod。
- 当前流水号乐观锁更新失败后没有在服务内部重新读取最新值并重试,而是直接返回业务失败。
因此,问题不是序列表存在重复记录。现场查询显示 short_id=02 + customize_code=8888 只有一条有效记录,序列表基础数据正常。
5.3 其他风险
截图中成功请求和李金海失败请求的 phoneNo 均为 15360423589。当前手机号唯一性校验采用“先查询、后创建”的方式,并发场景下两个请求可能同时通过前置查询。
即使解决了流水号竞争,后执行的请求也可能在内部账户落库或 IDaaS 创建阶段因为手机号重复而失败。需要确认两条业务数据是否确实应该使用同一个手机号,并检查数据库是否存在与业务规则一致的唯一约束。
同理,externalId + externalSource 也采用前置查询校验。若业务要求数据库级绝对唯一,应由唯一索引承担最终兜底,不能只依赖应用层“先查询再插入”。
6. 已提出的问题及结论
6.1 调用方是逐条处理,为什么服务端还是并发
“调用方计划逐条处理”和“服务端实际串行执行”不是一回事。可能存在以下情况:
- 上游使用了并行流或线程池。
- 使用异步调用后没有等待结果。
- 批量任务被多个消费者同时消费。
- 同一业务请求被拆成多个子 RPC 并行发送。
- 网关或调用框架发生重试。
本次两条日志的开始时间均为 10:24:38.865,且落在两个不同 Pod,因此服务端已经形成并发。
6.2 如何判断是不是并发访问
优先使用以下证据:
- 使用“结束时间减 RT”计算每次请求的开始时间。
- 比较两个请求的执行时间区间是否重叠。
- 查看是否属于相同 TraceId 下的不同 Span。
- 查看请求是否落到不同 Pod、线程或服务实例。
- 结合数据库 SQL 日志,确认是否读取了相同旧流水号。
如果链路追踪平台能够展示 Span 时间轴,两个 Span 的时间条重叠就是最直接的证据。
6.3 为什么恰好一个成功、一个失败
因为当前条件更新相当于只有一个竞争者能够修改旧流水号:第一个更新者成功,第二个更新者发现旧值已经变化,更新条数为 0,随即抛出“创建失败,请重试”。
6.4 needRetry=false 为什么和“请重试”冲突
错误消息来自流水号生成业务代码,但 needRetry=false 很可能来自统一响应包装的默认重试标识。二者语义不一致,容易误导调用方。
后续需要明确:
- 如果服务内部完成重试,对调用方不需要暴露该瞬时冲突。
- 如果仍要求调用方重试,应使错误码、错误文案和
needRetry标志保持一致,并结合幂等机制避免重复创建。
7. 领导提出的思路
7.1 “1 秒除以 RT”
RT 是一次请求从开始到结束的响应时间。单线程理论吞吐量可以粗略估算为:
单线程理论吞吐量 = 1000ms ÷ 平均RT本次成功请求 RT 为 210ms:
1000 ÷ 210 ≈ 4.76 次/秒含义是:如果一个线程必须等前一个请求结束后才能处理下一个,那么一秒理论上约完成 4.76 次成功请求。
若有 N 个并行工作线程,理论吞吐量可以粗略估算为:
N × 1000 ÷ 平均RT平均并发数则是另一个公式:
平均并发数 = QPS × RT(秒)
= QPS × RT(毫秒)÷ 1000例如 QPS 为 20、平均 RT 为 210ms:
平均并发数 = 20 × 210 ÷ 1000 = 4.2需要注意:1000 ÷ RT 只是容量粗估,不能单独用来证明某两次请求并发。本次是否并发是通过各自“结束时间减 RT”还原请求区间后确认的。失败请求 13ms 就提前结束,也不能拿它代表完整成功链路的正常 RT。
7.2 AtomicInteger 方案
AtomicInteger 是 JVM 内线程安全的原子计数器。在同一个 Pod 中,多个线程同时执行 incrementAndGet() 时会得到不同数字:
初始值 1581
线程 A:1582
线程 B:1583但本次请求落在两个不同 Pod,每个 Pod 有独立 JVM 和内存。如果两个 Pod 都从 1581 初始化:
Pod A:1581 → 1582
Pod B:1581 → 1582仍然会生成重复号码。因此,单独使用 AtomicInteger 只能解决单个 Pod 内的多线程竞争,不能解决多 Pod 分布式并发;Pod 重启、扩容后还存在计数值丢失和重新初始化的问题。
AtomicInteger 更适合配合号段模式使用:
Pod A 分配 1600~1699
Pod B 分配 1700~1799各 Pod 在自己的号段内使用 AtomicInteger 发号。这样能够降低数据库或 Redis 访问频率,但需要一个中心节点安全地分配不重叠号段。Pod 宕机时未使用的号码可能浪费,因此只适用于允许断号的业务。
7.3 Redis 方案
Redis 的 INCR 是原子操作。所有 Pod 对同一个 Key 自增时,返回值不会重复。
建议按应用简称和自定义编码划分 Key:
sso:account:sequence:02:8888当前值为 1581 时:
Pod A:INCR → 1582
Pod B:INCR → 1583Redis 方案能够直接解决多 Pod 的分布式发号问题,性能也较高,但需要处理以下事项:
- Key 不应随意设置过期时间,避免过期后从旧值重新发号。
- 首次初始化应使用
SETNX或 Lua 脚本,避免多个 Pod 同时覆盖初始值。 - 需要确认 Redis 持久化、主从切换和数据恢复策略,避免故障恢复到旧计数值。
- 如果需要检查
99999上限,应使用 Lua 脚本原子完成“检查上限 + 自增”。 - Redis 自增成功后,后续账号创建仍可能失败,产生断号。
- 数据库
account_id唯一索引仍需保留,作为最后一道防线。 - Redis 请求超时存在“服务端已自增、客户端未收到结果”的不确定性,重试时通常会跳过一个号码,因此系统必须接受断号。
8. 方案对比
假设两个 Pod 同时读取到流水号 1581:
| 方案 | Pod A | Pod B | 多 Pod 是否安全 | 特点 |
|---|---|---|---|---|
| 当前数据库乐观锁 | 更新为 1582 | 更新 0 行并失败 | 能防重复,但会报错 | 已实现冲突检测,缺少内部消化机制 |
| 单独使用 AtomicInteger | 得到 1582 | 也可能得到 1582 | 否 | 只适用于单 JVM |
| AtomicInteger + 号段 | 使用 A 号段 | 使用 B 号段 | 是 | 性能高,实现和运维复杂度较高 |
| Redis INCR | 得到 1582 | 得到 1583 | 是 | 简单高效,但增加 Redis 依赖和持久化要求 |
| 数据库乐观锁重试 | 更新为 1582 | 冲突后重新读取并更新为 1583 | 是 | 改动较小,适合当前并发量不高的场景 |
9. 建议处理顺序
9.1 先确认上游是否应当并发
使用本次 TraceId 检查上游链路,确认 .7 和 .9 两个 Span 的父子关系及时间轴,并检查批处理代码是否使用:
parallelStream- 线程池
CompletableFuture- 异步消息消费
- 未等待上一个 RPC 返回的循环提交
如果业务明确要求串行,应在上游保证前一条 RPC 完整返回后再发送下一条。但串行会限制吞吐量,平均 RT 为 210ms 时,单执行通道理论上只有约 4.76 次/秒。
9.2 短期优先考虑数据库冲突重试
保留现有乐观锁,在条件更新返回 0 后重新读取最新流水号,并进行有限次数重试,例如 3~5 次。重试可增加少量随机退避,避免多个竞争请求立即再次碰撞。
由于当前方法使用 REQUIRES_NEW,实际实现时需要结合生产数据库隔离级别验证“同一事务内重新查询”能否读取到其他事务刚提交的最新流水号。在可重复读隔离级别下,普通快照查询可能仍读到旧值,应考虑以下方式之一:
- 每次重试开启新的独立事务。
- 将重试查询调整为当前读/锁定读。
- 使用适合 OceanBase MySQL 兼容模式的数据库原子发号语句。
- 在评估锁等待和吞吐量后采用
SELECT ... FOR UPDATE串行更新。
不能只是在现有事务内无条件循环调用相同的普通查询,否则可能连续读到同一旧快照。
9.3 并发量较高时考虑 Redis
如果外部账号创建量较大、多个 Pod 会持续同时发号,可以使用 Redis INCR。需要同时完成初始化、持久化、故障恢复、最大值检查和数据库唯一约束设计,不能只增加一行 INCR 就上线。
9.4 AtomicInteger 不应单独使用
当前生产环境已经是多 Pod,因此单独使用 AtomicInteger 无法解决本次问题。只有在“中心节点分配号段、Pod 内 AtomicInteger 消费号段”的完整方案下才适合采用。
9.5 增加最终一致性保护
建议核对并完善以下数据库唯一约束:
account_idexternal_source + external_id- 手机号唯一规则对应的字段组合
t_abs_app_sequence(short_id, customize_code)
唯一约束的具体字段和历史数据影响需要在实施前确认。本文只记录建议,不直接执行任何数据库结构变更。
10. 后续定位所需日志
为了在下次冲突时形成完整证据,建议在流水号生成位置记录以下字段:
traceId
spanId
podName/containerIp
threadName
sequenceRowId
shortId
customizeCode
oldSeqNo
newSeqNo
updateCount
查询时间
更新时间重点确认两次请求是否执行了类似下面的条件更新:
请求 A:WHERE id=4 AND seq_no=1581,updateCount=1
请求 B:WHERE id=4 AND seq_no=1581,updateCount=0如果能够拿到这一组 SQL 参数,就可以在应用日志、链路日志和数据库行为三个层面完整证明本次流水号竞争。