臭名昭著aTrust隔离计划1.臭名昭著aTrust隔离计划2.VPN原理与aTrust隔离网络实践3.docker-easyconnect到底做了什么4.TUN(tunnel-隧道-虚拟网卡)模式
个人项目
personal(个人展示)GitHub Actions 自动部署个人展示项目
基于Quartz搭建的个人博客1.Quartz个人博客使用教程2.使用 rsync 增量部署 Quartz 博客3.使用 GitHub Actions 自动部署 Quartz 博客5.域名绑定6.CDN加速Obsidian、双向链接与知识图谱
旅行
行程攻略青岛三日游行程攻略
前端
NginxNginx配置与反向代理入门
Nodenpm与npx的区别
中华财险-公司项目
对外接口文档
基础数据码表对外接口(仅支持rpc调用)业务码表对外接口应用主数据对外接口
基础运营查询版本更新日志详情消息中心对外接口站内信对外接口站内信模板配置手册
权限中心权限中心对外接口文档(最新)权限中心对外接口文档前端ACL-CORE FACADE依赖版本
审计中心审计中心对外接口文档audit-center-facade版本
审批中心工作流迁审批流现状工作流迁审批流API能力替换方案老审批流接口文档审批中心接口文档审批中心业务回调FAQ
账号中心内部系统对接单点登录内部系统对接认证中心三方网页应用登录授权账号中心对外接口文档前端账号中心RPC接口文档账户变更对外广播消息文档账户中心对外接口HRMS外部员工变更广播消息文档HRMS外部员工对外接口
组织员工岗位🔥机构映射SDK接口文档员工岗位变更通知说明组织机构管营一体概念与用法组织员工对外广播消息文档组织员工岗位对外接口文档组织员工岗位对外接口文档前端组织员工岗位数据模型组织员工主数据业务场景案例organization-facade版本
hrms(大型人力资源外包管理系统)
项目架构说明HRMS Maven模块与依赖说明
项目说明0.流程中心模块0.组织模块说明1.业务线模块2.计划模块3.项目模块4.协议模块5.供应商模块6.合约域模块7.外包人员模块*核心:外包人员生命周期
项目运维外包人员项目编制差异排查与修复2.HRMS相关问题排查4.修改externalId(externalId和accountId不一致)
需求-系分
1.内部转外包0723外包人员关联历史内部账号系分新增外包人员关联历史账号内容(紧急0723上线)需求
2.工作岗位0820工作岗位0820需求工作岗位0820需求-系分
3.用工模式调整0917内部人员转外包用工系统需求2内部人员转外包用工系统需求-系分
sso(账号中心-单点登录)
项目架构说明aboss-sso项目架构入门AOP统一日志打印链路Maven多模块项目高级知识OAuth2.0
项目说明1.SSO-OAuth2.0与IDaaS登录流程2.外部账号创建流水号并发问题分析
AI
使用说明
第三方插件&技能简介Archify使用与安装指南Ponytail使用指南
CodexCodex CLI与IDE区别及使用指南Codex Hook单独配置与提交通知Codex MCP安装与使用指南Codex第三方插件安装与使用指南
Agent开发1.LLM、Token、上下文窗口与模型参数2.大模型 API、请求参数与响应结构3.Spring AI ChatClient4.Prompt、System Prompt、Prompt 模板6.结构化输出、JSON Schema7.SSE 流式响应8.会话 ID、聊天记录、Redis9.超时、重试、限流、降级10.完成可运行聊天接口
GitGit常用命令与Obsidian推送排查
Java
面试题Java基础与集合面试题
AtomicInteger原子计数与并发安全ConcurrentHashMap并发安全与计数Java线程、线程池与Future
python
基础Python基础语法
HTTPXHRMS员工详情接口调用(Python HTTPX)

外部账号创建流水号并发问题分析

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,主要流程如下:

  1. 校验入司日期和离司日期。
  2. 校验手机号是否已被在职员工使用。
  3. 校验 externalId + externalSource 是否唯一。
  4. 校验外部来源是否具备接口创建权限。
  5. 校验外部负责人 externalHead
  6. 校验外部业务分类 externalClassification
  7. 校验自定义编码 customCode
  8. 生成外部账号 accountId
  9. 创建内部账户。
  10. 创建 IDaaS 账户。
  11. 建立关联账户关系。
  12. 发送账户变更 MQ。

本次错误出现在第 8 步“生成外部账号 accountId”,因此前面的日期、外部来源、负责人和业务分类等校验已经通过,不是这些入参直接导致的错误。

3.2 账号编码组成

外部账号格式为:

zhex + 2 位应用简称 + 4 位自定义编码 + 5 位流水号

本次请求没有传入 customCode,系统从应用路由中读取默认自定义编码。当前现场配置为:

字段
short_id02
customize_code8888
序列表主键 id4
is_valid1
tenant_codecic

例如流水号为 1582 时,生成的账号为:

zhex02888801582

3.3 当前流水号生成方式

AppSequenceGatewayImpl.externalId() 使用独立事务 REQUIRES_NEW 生成流水号:

  1. 根据 externalSource=M010017 查询应用路由。
  2. 得到 short_id=02 和默认 customize_code=8888
  3. 查询 t_abs_app_sequence 当前 seq_no
  4. 使用主键和旧流水号进行条件更新:
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:

0aced0b01788315878390884326

Span 分别为:

0.1.1.1.7
0.1.1.1.9

请求被分发到了两个不同的 Pod:

请求结果容器 IPPod日志结束时间RT
成功10.206.215.44midaboss-sso-cell-gz00a-vmht5-nxgrj10:24:39.075210ms
失败10.206.206.117midaboss-sso-cell-gz00a-vmht5-xzks610:24:38.87813ms

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=1581

Pod A 先完成条件更新:

WHERE id=4 AND seq_no=1581
更新成功,数据库变成 seq_no=1582

Pod B 再使用旧值更新:

WHERE id=4 AND seq_no=1581
数据库已经是 seq_no=1582,更新条数为 0

于是 Pod A 继续完成账号创建并返回 zhex02888801582,Pod B 在 13ms 内快速返回“创建失败,请重试”。这与现场“一条成功、一条失败”的表现一致。

上述流水号值是依据成功账号后缀和当前代码还原的高概率过程。要形成数据库级最终证据,还应查看同一 TraceId 下两次 SELECTUPDATE 的实际 SQL 参数及影响行数。

5.2 根本原因

根本原因包括两层:

  1. 上游实际并发发送了两个外部账号创建请求,且请求被负载均衡到不同 Pod。
  2. 当前流水号乐观锁更新失败后没有在服务内部重新读取最新值并重试,而是直接返回业务失败。

因此,问题不是序列表存在重复记录。现场查询显示 short_id=02 + customize_code=8888 只有一条有效记录,序列表基础数据正常。

5.3 其他风险

截图中成功请求和李金海失败请求的 phoneNo 均为 15360423589。当前手机号唯一性校验采用“先查询、后创建”的方式,并发场景下两个请求可能同时通过前置查询。

即使解决了流水号竞争,后执行的请求也可能在内部账户落库或 IDaaS 创建阶段因为手机号重复而失败。需要确认两条业务数据是否确实应该使用同一个手机号,并检查数据库是否存在与业务规则一致的唯一约束。

同理,externalId + externalSource 也采用前置查询校验。若业务要求数据库级绝对唯一,应由唯一索引承担最终兜底,不能只依赖应用层“先查询再插入”。

6. 已提出的问题及结论

6.1 调用方是逐条处理,为什么服务端还是并发

“调用方计划逐条处理”和“服务端实际串行执行”不是一回事。可能存在以下情况:

  • 上游使用了并行流或线程池。
  • 使用异步调用后没有等待结果。
  • 批量任务被多个消费者同时消费。
  • 同一业务请求被拆成多个子 RPC 并行发送。
  • 网关或调用框架发生重试。

本次两条日志的开始时间均为 10:24:38.865,且落在两个不同 Pod,因此服务端已经形成并发。

6.2 如何判断是不是并发访问

优先使用以下证据:

  1. 使用“结束时间减 RT”计算每次请求的开始时间。
  2. 比较两个请求的执行时间区间是否重叠。
  3. 查看是否属于相同 TraceId 下的不同 Span。
  4. 查看请求是否落到不同 Pod、线程或服务实例。
  5. 结合数据库 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 → 1583

Redis 方案能够直接解决多 Pod 的分布式发号问题,性能也较高,但需要处理以下事项:

  1. Key 不应随意设置过期时间,避免过期后从旧值重新发号。
  2. 首次初始化应使用 SETNX 或 Lua 脚本,避免多个 Pod 同时覆盖初始值。
  3. 需要确认 Redis 持久化、主从切换和数据恢复策略,避免故障恢复到旧计数值。
  4. 如果需要检查 99999 上限,应使用 Lua 脚本原子完成“检查上限 + 自增”。
  5. Redis 自增成功后,后续账号创建仍可能失败,产生断号。
  6. 数据库 account_id 唯一索引仍需保留,作为最后一道防线。
  7. Redis 请求超时存在“服务端已自增、客户端未收到结果”的不确定性,重试时通常会跳过一个号码,因此系统必须接受断号。

8. 方案对比

假设两个 Pod 同时读取到流水号 1581

方案Pod APod 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_id
  • external_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 参数,就可以在应用日志、链路日志和数据库行为三个层面完整证明本次流水号竞争。