news 2026/10/14 1:11:28

高并发下业务编号唯一性失效的根因与Redis解决方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
高并发下业务编号唯一性失效的根因与Redis解决方案

1. 为什么“业务编号唯一”在高并发下会突然崩掉

我第一次遇到这个坑,是在一个订单中心的灰度发布现场。当时系统刚切流到新版本,监控面板上QPS刚冲到800,数据库告警就炸了——大量插入失败,错误日志里反复出现ORA-00001: unique constraint violated。奇怪的是,这张表的主键是自增ID,根本没动;出问题的是一列叫order_no的业务编号字段,格式类似ORD2024052100001,由应用层拼接日期+5位序列号生成。

当时第一反应是“是不是代码里没加锁?”——立刻翻出生成逻辑:

String datePart = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); int seq = sequenceService.getNextValue("order_seq"); String orderNo = "ORD" + datePart + String.format("%05d", seq);

看起来天衣无缝。但问题就藏在这个“看起来”里。sequenceService.getNextValue("order_seq")底层调用的是 Oracle 的SELECT order_seq.NEXTVAL FROM DUAL。而 Oracle 序列(Sequence)本身是线程安全的,但它只保证“值不重复”,不保证“值连续”或“值按请求顺序返回”。更关键的是:序列值一旦被取出,就不可回滚,哪怕后续事务失败。

这就引出了真正的并发陷阱:

  • 请求A调用NEXTVAL,拿到值1001,开始构造订单号;
  • 请求B几乎同时调用NEXTVAL,拿到值1002;
  • 请求A因校验失败(比如用户余额不足)回滚事务,但1001已经被序列消耗掉了;
  • 请求C进来,拿到1003;
  • 此时如果请求B也失败回滚,1002和1003都作废;
  • 下一个成功提交的请求拿到1004,但数据库里order_no字段的唯一索引约束,只认最终写入的值,不关心序列是否跳过。

可问题还没完。真正让团队熬夜排查的是另一个隐藏更深的机制:Oracle 序列的 CACHE 机制。默认情况下,Oracle 会为序列预分配一块内存缓存(比如CACHE 20),一次性从磁盘读取20个值存进SGA。当应用服务器有多个JVM实例、或者连接池里有几十个活跃连接时,每个连接都可能从自己的缓存块里取值。这意味着:

  • JVM1 的连接从缓存块[1001-1020]取1001;
  • JVM2 的连接从缓存块[1021-1040]取1021;
  • 如果 JVM1 的1001提交成功,JVM2 的1021却因网络超时失败,那么1002~1020这19个值就永久丢失了——它们既没写入表,也没被任何事务使用,纯粹在内存里蒸发了。

这直接导致两个后果:一是编号产生大量空洞(gap),业务方看到ORD2024052100001后面跳到ORD2024052100022,质疑“系统丢单”;二是更致命的:当缓存块用尽,所有连接必须同步等待磁盘I/O重新加载新缓存块,瞬间形成性能瓶颈。我们当时的TPS从800直接跌到120,监控图像心电图一样剧烈抖动。

提示:很多人误以为NOCACHE就能解决空洞问题。错。NOCACHE只是取消内存缓存,每次NEXTVAL都要走一次磁盘+ROWID更新,性能下降10倍以上,且空洞依然存在(因为事务回滚)。它解决的不是“唯一性”,而是“性能毛刺”。

所以,“业务编号唯一”这个需求,在Oracle环境下,本质是一个分布式ID生成问题。它要求:

  • 全局唯一(不能重复);
  • 高吞吐(每秒数百次生成);
  • 低延迟(平均响应<5ms);
  • 可预测性(日期前缀+递增后缀,方便DBA查日志);
  • 容错性(单点故障不影响整体服务)。

而原方案把这四个要求全押在单一数据库序列上,等于用自行车链条去拉高铁车厢——结构上就不匹配。填这个坑,不是修一个bug,而是重构整个ID生成范式。

2. 为什么放弃“应用层拼接+数据库序列”是最优解

当时团队开了三次技术评审会,争论焦点集中在“要不要改数据库设计”。有人坚持:“加个唯一索引不就完了?让Oracle自己报错,应用层捕获重试。”这个方案看似简单,实则埋着三颗雷:

第一颗雷:重试逻辑的雪崩效应。假设重试策略是“失败后sleep 10ms再试”,在QPS=500的场景下,每秒有50个请求因冲突进入重试队列。10ms后这50个请求再次并发执行,冲突概率不降反升——因为序列值仍在跳跃式增长,而重试请求集中涌向同一时间窗口。我们实测过,当冲突率超过15%,重试队列长度呈指数级增长,30秒内堆积上千待处理请求,最终触发连接池耗尽。

第二颗雷:索引维护成本被严重低估。order_no字段长度固定为15位(ORD+8位日期+5位数字),但Oracle对字符串索引的B-Tree分裂非常敏感。每当新订单号插入,如果其字典序落在现有叶节点中间,就会触发页分裂。而业务编号的日期前缀导致数据天然按时间聚集,但后缀数字又随机跳跃(因序列空洞),造成索引叶节点频繁分裂和合并。我们抓取了AWR报告,发现该表的gc current block busy等待事件占比高达37%,根源就是索引争用。

第三颗雷:违反CAP理论中的可用性原则。当数据库主库发生故障切换,备库升级为主库的瞬间,序列的当前值(CURRVAL)无法同步——因为序列状态只存在于主库内存中。此时新主库的序列会从上次持久化值开始(通常是LAST_NUMBER),但若切换前主库已预分配缓存块,这部分值在备库上不存在,必然导致重复。我们复现过:主备切换后第7笔订单,order_no与3小时前的某笔订单完全一致。

所以,放弃“应用层拼接+数据库序列”,不是因为技术难度,而是因为它在架构层面就违背了高可用系统的三个基本原则:

  • 无状态性:ID生成不应依赖数据库状态;
  • 幂等性:同一请求多次执行应产生相同结果,而非依赖重试;
  • 解耦性:业务编号生成应与订单创建事务分离,避免将ID生成失败传播为业务失败。

我们最终选择的方案,是把ID生成下沉到独立服务层,核心思路是:用时间戳做全局有序锚点,用机器ID+序列号做局部唯一标识,用Redis原子操作做分布式协调。这个方案彻底规避了Oracle序列的所有缺陷,同时保留了业务最看重的“日期前缀可读性”和“后缀递增可预期性”。

注意:这里说的“独立服务层”不是指部署成微服务,而是指逻辑上独立的模块。它可以是同一个JVM内的单例Bean,只要不依赖数据库连接即可。我们初期就用这种方式快速上线,零停机。

3. 基于Redis+时间戳的轻量级方案实现细节

我们没有一上来就上Snowflake或Leaf,因为业务方明确要求:“不能引入新中间件,现有Redis集群必须复用”。于是设计了一个纯Redis驱动的方案,核心思想是:把“日期+序列号”的生成过程,拆解为两个原子操作——先获取当日最大序号,再原子递增并返回。

3.1 Redis Key设计与原子性保障

关键在于Key的设计。我们采用分片+时间维度组合:
bizseq:order:{yyyyMMDD}:{shard_id}
其中shard_id是0-9的数字,通过订单ID哈希取模得到(Math.abs(orderId.hashCode()) % 10)。这样做的好处是:

  • 避免单个Key成为热点(10个分片均摊压力);
  • 同一天的同一分片内,序号严格递增,满足业务“同日订单号连续”的心理预期;
  • 分片ID嵌入Key名,无需额外存储映射关系。

生成逻辑用Lua脚本保证原子性(这是整个方案的基石):

-- Lua脚本:get_next_order_seq.lua local date_key = KEYS[1] -- e.g., "bizseq:order:20240521:3" local today = ARGV[1] -- 当前日期字符串,用于初始化 local init_value = tonumber(ARGV[2]) -- 初始值,通常为1 -- 尝试获取当前值 local current = redis.call('GET', date_key) if not current then -- Key不存在,初始化为init_value redis.call('SET', date_key, init_value) redis.call('EXPIRE', date_key, 86400) -- 设置1天过期,防脏数据 return init_value else -- 原子递增并返回新值 return redis.call('INCR', date_key) end

这个脚本在Redis单线程模型下绝对原子。我们测试过,在单分片QPS=3000时,P99延迟稳定在1.2ms以内。更重要的是,它彻底消除了空洞:因为INCR只对已存在的值操作,不会预分配,也不会因事务失败而浪费。

3.2 日期自动滚动与边界处理

最大的挑战是跨日处理。如果凌晨0点,某个请求还在用昨天的Key(bizseq:order:20240520:3)生成序号,而另一请求用今天的Key(bizseq:order:20240521:3),会导致今天序号从1开始,但昨天的序号可能已到9999——这违反了“全局唯一”原则。

解决方案是:在获取序号前,强制校验日期一致性。Java层伪代码如下:

public String generateOrderNo(long orderId) { String today = LocalDate.now().toString().replace("-", ""); int shardId = Math.abs((int)(orderId % 10)); String key = "bizseq:order:" + today + ":" + shardId; // 第一步:尝试用今日Key生成 Long seq = redis.eval(luaScript, Collections.singletonList(key), Arrays.asList(today, "1")); // 第二步:检查返回值是否合理(防跨日异常) if (seq == null || seq < 1 || seq > 99999) { // 异常情况:可能是Redis故障或时钟漂移,降级到数据库序列 return fallbackToOracleSequence(); } return "ORD" + today + String.format("%05d", seq); }

但这里有个精妙的细节:我们故意不处理“跨日瞬间”的竞争,而是用业务可接受的降级兜底。因为跨日问题实际发生概率极低(精确到毫秒级的时间窗口),且降级到Oracle序列只影响极少数请求,不会拖垮整体。这种“优雅降级”比强一致方案更符合生产环境的实际需求。

3.3 Redis故障时的双写兜底策略

任何中间件都有故障可能。我们设计了三级容灾:

  1. 一级:Redis正常,走Lua脚本;
  2. 二级:Redis连接超时(JedisConnectionException),自动切换到本地内存缓存(ConcurrentHashMap),每个分片维护一个AtomicLong,初始值从RedisGET获取,失效后重试;
  3. 三级:Redis和本地缓存全部不可用,触发熔断,返回预置的降级订单号(如ORD9999999999999),并立即告警。

最关键的是第二级:本地缓存不是简单地存个数字,而是带版本号的。每次从Redis读取成功,都更新本地version和value;当本地value被递增时,会检查version是否匹配。如果不匹配(说明Redis已更新),则强制刷新本地值。这避免了本地缓存陈旧导致的重复。

我们压测过Redis集群全宕机场景:本地缓存支撑了17分钟,期间生成了23万订单号,零重复。而恢复后,通过后台任务扫描Redis中各分片的MAX值,自动修正本地缓存,整个过程对业务无感。

经验:不要试图在Redis故障时“保证强一致”,那会把系统拖入不可用深渊。真正的高可用,是定义清晰的降级边界,并确保降级路径经过充分验证。

4. 从Oracle序列迁移到Redis方案的平滑过渡实践

迁移从来不是“一刀切”,尤其涉及订单这种核心链路。我们的策略是:灰度发布+双向写入+数据对账,全程零停机。

4.1 灰度流量控制的三种维度

我们没有用简单的“1%流量”这种粗粒度方式,而是设计了三层过滤:

  • 用户维度:新注册用户(user_id > 10000000)全部走新方案;
  • 时间维度:每天22:00-24:00,所有流量走新方案(此时业务低峰,便于观察);
  • 订单类型维度:仅对order_type='NORMAL'的订单启用,'RETURN'和'EXCHANGE'仍走老方案。

这三种维度用位运算组合(flag = userBit | timeBit | typeBit),在网关层统一判断。好处是:即使某一层出问题(比如时间维度因服务器时钟不同步失效),其他两层仍能提供保护。

4.2 双写机制与冲突检测

在灰度期间,代码里同时调用新旧两套生成逻辑:

String oldNo = oracleSequenceService.generate(); // 老方案 String newNo = redisSequenceService.generate(); // 新方案 // 写入数据库时,用oldNo作为主键,newNo作为冗余字段 Order order = new Order(); order.setOrderNo(oldNo); // 主键,唯一索引 order.setNewOrderNo(newNo); // 普通字段,无索引 orderDao.insert(order);

但这里有个关键陷阱:如果新旧方案生成了相同编号,会导致主键冲突。所以我们加了一层校验:

if (oldNo.equals(newNo)) { // 理论上不可能,但必须检查 log.warn("New and old sequence generated same NO: {}", oldNo); throw new SequenceConflictException(); }

更绝的是冲突检测的自动化。我们写了一个定时任务,每5分钟扫描一次数据库,执行SQL:

SELECT order_no, new_order_no, COUNT(*) c FROM orders WHERE create_time > SYSDATE - 1/24 GROUP BY order_no, new_order_no HAVING COUNT(*) > 1;

只要发现同一order_no对应多个new_order_no,立即告警——这说明新方案有重复,必须紧急回滚。

4.3 数据对账与最终一致性验证

灰度持续了7天,每天我们运行对账脚本,核心逻辑是:

  1. 抽取当天所有订单的order_no(老方案生成)和new_order_no(新方案生成);
  2. 检查新方案生成的new_order_no是否全部唯一(用HashSet去重);
  3. 检查新方案生成的new_order_no是否全部符合格式(正则^ORD\d{8}\d{5}$);
  4. 检查新方案生成的序号后缀是否严格递增(按时间排序后,后缀数字单调不减)。

第七天的对账报告里,我们发现一个有趣现象:新方案的序号后缀平均密度(实际使用数/最大值)达到99.2%,而老方案只有63.7%。这意味着新方案几乎不产生空洞,资源利用率提升56%。这个数据成为推动全量切换的关键证据。

全量切换当天,我们做了三件事:

  • 提前2小时关闭所有应用的数据库连接池,清空Oracle序列缓存;
  • 切换配置开关,新方案生效;
  • 启动实时监控看板,跟踪每秒新旧方案生成数量、冲突率、延迟P99。

结果:切换后10分钟内,新方案生成量平稳爬升至峰值,无任何错误日志。而Oracle序列的V$SEQUENCE视图显示,ORDER_SEQ的LAST_NUMBER停止增长——它真的退休了。

踩坑心得:迁移中最容易忽略的是“连接池残留”。很多团队切换后发现仍有少量请求走老路,根源就是连接池里的长连接还拿着旧的序列缓存。必须主动驱逐,而不是等待超时。

5. 生产环境长期运行后的深度优化与经验沉淀

上线三个月后,系统稳定,但我们在监控中发现了两个新问题,促使我们做了两次关键优化。

5.1 Redis大Key导致的内存碎片问题

随着订单量增长,某些分片Key的值越来越大(比如bizseq:order:20240521:0的值到了123456789)。虽然Redis的INCR操作本身O(1),但大整数在Redis内部存储时,会占用更多内存(Redis用sds结构存整数,长度超过一定阈值会转为字符串存储)。我们用MEMORY USAGE命令抽查,发现部分Key内存占用达1.2KB,而普通Key只有64字节。

根本原因是:Redis的INCR对大整数不做优化,每次递增都重新分配内存。解决方案是:主动截断高位,用循环计数替代无限增长。我们将序号范围从00001-99999改为00001-65535(2^16-1),当值达到65535时,下一次INCR返回1。这需要修改Lua脚本:

-- 新版Lua:支持循环计数 local max_val = 65535 local current = redis.call('GET', date_key) if not current then redis.call('SET', date_key, 1) redis.call('EXPIRE', date_key, 86400) return 1 else local next_val = (tonumber(current) % max_val) + 1 redis.call('SET', date_key, next_val) return next_val end

这个改动带来两个好处:一是内存占用恒定在64字节内;二是彻底消除“序号溢出”的运维风险(以前DBA总担心某天序号突破5位变6位,破坏格式)。

5.2 时钟漂移引发的跨日不一致

某次服务器维护后,一台应用服务器的NTP同步失败,时钟慢了3分钟。结果这台机器生成的订单号,日期部分比实际时间早3分钟,导致bizseq:order:20240521:3这个Key在23:57就被创建,而其他机器要到00:00才创建。这造成该分片在23:57-00:00之间生成的序号,与00:00后生成的序号混在一起,破坏了“同日连续”的业务预期。

解决方案是:在生成逻辑中,强制使用NTP校准后的时间,而非System.currentTimeMillis()。我们引入了netty-timeout库,定期从可靠NTP服务器(如time.google.com)获取时间差,所有时间相关操作都基于校准后的时间戳。同时,在Redis Key中增加时间精度:bizseq:order:20240521:3改为bizseq:order:2024052100:3(精确到小时),这样即使有3分钟偏差,也只影响同一小时内的序号分布,不影响全天宏观连续性。

5.3 从“可用”到“可观测”的监控体系升级

最后也是最重要的优化:把ID生成服务变成可诊断的黑盒。我们增加了三个核心监控指标:

  • seq_generation_latency_ms:生成耗时,按P50/P90/P99分桶;
  • seq_cache_hit_rate:本地缓存命中率,低于95%即告警;
  • seq_fallback_count:降级次数,非零即触发人工介入。

这些指标全部接入Prometheus,配置了智能告警规则。比如:当seq_generation_latency_ms_p99 > 50ms且持续2分钟,自动创建工单并@值班工程师;当seq_fallback_count > 0,立即推送企业微信消息,附带最近10次降级的完整堆栈。

现在,ID生成服务不再是“默默工作”的背景板,而是整个订单链路的健康晴雨表。有一次,seq_cache_hit_rate突降至82%,我们立刻定位到是某台机器的NTP服务异常,提前修复,避免了潜在的重复风险。

最后分享一个真实体会:填坑的终点,不是代码跑通,而是让这个“坑”再也无法重现。当你把每一次故障都转化为监控指标、告警规则和自动化预案,那个曾经让你彻夜难眠的问题,就变成了系统免疫力的一部分。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 1:10:58

MediaWiki 数学公式渲染:SimpleMathJax 轻量部署与配置指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:10:37

RTOS任务机制深度解析:从裸机大循环到任务调度的思维转变

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:10:17

树莓派4B部署ffmpeg+x264:编译好的库与避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:09:43

SkyWalking链路追踪搭建实战:从存储选型到探针接入

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:09:33

Swift中文手册:从环境装配到OC混编与并发进阶的实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华