ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

JVS逻辑引擎自增组件:业务编号生成解耦与可靠性实践

JVS逻辑引擎自增组件:业务编号生成解耦与可靠性实践 我最近在review一个老项目的代码时又看到了一段折磨人的编号生成逻辑拼接前缀、判断日期、查库里当前序号再加一、按指定位数补零……光是这个逻辑就写了六十多行而且分散在四个服务里每个服务各自实现了一遍。业务方提了个需求说编号前缀改一下我改完两个服务以为完事了结果第三个服务用了完全不同的实现前缀改了但位数规则没对上测试环境复现不出来上线第二天线上就出了两笔编号重复的单子。那段经历让我下决心认真研究了一下JVS逻辑引擎的自增组件。JVS逻辑引擎简单说是一个把业务逻辑可视化编排出来的平台可以像搭积木一样把判断、计算、数据操作串成一张流程图然后对外提供接口调用。自增组件就是其中专门处理按规则自动生成递增序列的组件比如订单号、合同号、流水号、申请单号的批量生成场景。它要解决的核心问题正是业务规则与实现解耦业务想改规则不用等开发排期、不用重新发版实现层面的可靠性比如并发防重、持久化、事务一致性交给引擎统一兜底。这篇文章适合正在做业务系统、被各种编号需求反复折腾的开发者也适合想评估低代码逻辑引擎到底能解决什么问题的技术负责人。我会从原理拆到实操再讲我在真实项目里踩过的坑尽量让你看完就能判断这种配置化方案到底适不适合自己的团队。1. 编号生成这种小事怎么就成了业务逻辑的重灾区1.1 传统代码实现里的固定套路和隐藏成本绝大多数团队第一次做编号生成时都会写出类似下面这样的代码public String generateOrderNo(String tenantId) { String date LocalDate.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)); Integer seq redisTemplate.opsForValue().increment(order:seq: tenantId : date); if (seq 9999) { throw new BizException(当日订单号超出上限); } return ORD- tenantId date String.format(%04d, seq); }这段代码看起来没什么问题前缀固定、按日期重置、按租户隔离、用Redis的INCR保证并发不重复。但它的问题不在能不能跑通而在后面怎么维护。第一个隐藏成本是规则与代码强绑定。一旦业务要求把前缀从ORD-改成SO-把4位流水改成6位或者从按天重置改成按月重置你必须改代码、走发布流程、通知下游调用方排查是否有缓存。如果这个逻辑在订单服务、售后服务和财务服务里各有一份那就要改三个地方测试还得三个服务一起回归。规则变化越频繁这种绑定的代价就越大。第二个隐藏成本是实现细节不一致。团队里每个开发者对并发防重的理解不一样有的人用Redis INCR有的人查MAX加一还有的人直接搞数据库唯一索引靠异常兜底。表面上看大家都在生成编号实际上边界行为差异很大尤其是并发稍微高一点的场景用查MAX加一实现的接口很容易在同一毫秒内生成两个相同的号。第三个隐藏成本是故障处理的随意性。序号达到9999之后怎么办有人抛异常有人循环从头开始有人直接让它变成10000。这些边缘决策通常没有任何文档全写在代码注释角落里等出了生产事故才被人翻出来。1.2 业务方和开发方在编号这件事上关注点完全错位业务方关心的问题永远是编号长什么样前缀是字母还是数字日期用什么格式流水号按什么维度隔离是按租户、按门店、还是按业务类型什么周期重置按天、按月、按年返回给客户的编号位数固定吗要不要补零开发方关心的问题则是并发下会不会重复状态是存Redis还是MySQL重启丢不丢事务里调用会不会拖慢主流程这个接口的TPS能撑多少你会发现双方关心的东西几乎不重叠。业务方说的每一句话都指向规则本身开发方说的每一句话都指向实现机制。当这两层东西堆在同一个方法里、同一段代码里的时候任何一方的变化都会牵扯到另一方。这就是耦合的真面目不是文件之间的依赖关系而是规则变更和实现变更被强制绑定了。1.3 为什么逻辑引擎会盯上这个场景自增组件切入的正是这个错位地带。它的核心做法是把规则抽到配置层把实现沉淀在引擎层。引擎层负责所有与怎么稳定生成一个递增序列有关的脏活累活包括但不限于并发控制、持久化、事务边界、异常恢复。规则层则只保留业务方真正关心的那几项配置前缀什么、日期格式什么、位数几位、按什么维度隔离、多久重置一次。这两层分离之后业务方改规则就是改配置开发方不用碰实现。更关键的是配置是有结构、有校验、有版本的改之前可以评估影响范围改之后可以追溯历史记录比散落在代码里的if/else规矩多了。2. 自增组件的解耦思路规则入配置实现进引擎2.1 解耦的本质是重新划分变化边界很多团队一谈解耦就想到微服务拆分、消息队列、事件驱动其实那是另一个维度的事。对于业务规则这种高频变化的东西更务实的解耦方式是把变化的部分与不变的部分分开。在编号生成这个场景里什么是不变的需要有一个原子递增的计数器计数器需要持久化不能因为重启丢失并发请求只能拿到唯一的值生成结果需要在事务边界内保持一致什么是经常变的前缀后缀的文案日期格式分隔符位数和补零规则重置周期按哪个字段做隔离维度自增组件的设计就是把上面两行拆开第一行由引擎内部实现第二行对外暴露为配置项。这样改规则和改实现变成了两个独立维度的动作互不干扰。2.2 自增组件在引擎内部替你封装了什么从使用者的角度看自增组件是一个输入参数、输出编号的黑盒。但如果你知道它内部大致做了什么配置的时候会更有底。常规情况下一个合格的自增组件至少要封装四件事第一计数器的原子递增。底层可以用数据库的UPDATE ... SET value value 1 WHERE key ?原子操作也可以基于Redis的INCR两者各有优劣。数据库方案强一致、可持久化但吞吐受限于数据库Redis方案性能好但要考虑持久化策略和主从切换的极端情况。引擎一般会把这两种后端做成可切换的配置供不同规模的项目选择。第二隔离维度的动态组合。配置里指定的维度字段比如租户ID、门店ID、业务类型会被拼进计数器的存储Key里。你配置了tenantId和date两个维度引擎就会生成类似counter:orderNo:tenantA:20241012的Key各维度之间天然隔离。第三序列值的持久化和恢复。就算计数器存储用的是Redis引擎也会定期把当前值同步到数据库或者在每次生成后写一条流水记录保证极端情况下的可恢复性。这是可靠性很重要但很容易被忽略的一层。第四溢出和异常策略。序列到达最大值之后是抛异常、归零继续、还是扩位数每种策略都对应一套完整的行为约定由配置显式声明而不是靠开发者临场写。2.3 规则变更时两种方式的链路对比把传统方式和配置化方式的变更流程放在一起看差别非常直观。变更场景传统代码方式逻辑引擎配置化方式编号前缀从ORD改为SO改代码、发版、回归、通知下游打开逻辑配置改prefix参数保存重置周期从按天改为按月改日期判断逻辑、处理缓存Key、发版重置策略下拉框改为MONTHLY新增一个隔离维度按门店改Key拼接逻辑、改表结构或Redis Key规划、发版配置项里增加维度字段引擎自动拼接流水位数从4位改为6位改格式化代码还要处理已有编号是否冲突改seqLength参数并发下偶发重复写代码排查竞态通常要重构存储方案底层机制由引擎保障业务配置不变我见过太多团队在改编号规则这件事上消耗的人力比做业务功能还多因为规则改完经常牵出并发、存储、历史数据对齐等一系列连锁问题。配置化之后大部分变更被压缩成了改一个参数、发一次逻辑版本研发成本降了一个量级。3. 实操在JVS逻辑引擎上配置一个多租户订单号自增规则3.1 先想清楚编号规则长什么样再动手我习惯先拿一张纸把规则完整写出来再去页面上配置。一个清晰的规则描述比任何文档都有用比如这条编号格式固定前缀 租户标识 日期 4位流水示例ORS-T001202410120001固定前缀ORS-隔离维度租户IDT001和日期20241012流水位数4位不足补零初始值1步长1重置周期按天溢出策略当日流水超出9999时报错规则一旦写清楚映射到配置项就是顺理成章的事。绝大多数配置问题都出在规则本身没想清楚而不是配置界面难用。3.2 在逻辑引擎里的配置过程进入JVS平台的逻辑引擎新建一个逻辑命名类似订单号生成然后按下面这些步骤操作在组件面板中找到自增组件拖到画布上。双击组件打开配置面板填写3.1节里梳理好的参数。配置输入参数比如tenantId引擎会用这个字段的值参与维度隔离。在自增组件后面接一个返回节点把生成的编号作为逻辑输出。保存逻辑发布版本。配置参数表大致长这样配置项含义本次示例值prefix固定前缀ORS-dimensionFields隔离维度字段tenantIddatePattern日期格式yyyyMMddseqLength流水号位数4initialValue初始值1step递增步长1resetStrategy重置策略DAILYoverflowPolicy溢出策略THROW_ERRORcounterBackend计数器存储后端REDIS底层会生成一份类似这样的配置结构{ componentType: increment, config: { prefix: ORS-, dimensionFields: [tenantId], datePattern: yyyyMMdd, seqLength: 4, initialValue: 1, step: 1, resetStrategy: DAILY, overflowPolicy: THROW_ERROR, counterBackend: REDIS } }发布之后外部系统通过HTTP接口或引擎提供的方式调用这个逻辑传入tenantId拿回生成好的编号。调用方根本不需要知道编号规则是怎么实现的它只知道传一个租户ID拿回一个唯一编号。3.3 用一次真实的需求变更验证配置化价值上线运行两周后业务方提了两个需求订单号前缀从ORS-改成SO-重置周期从按天改成按自然月因为财务要按月对账。传统做法下这条需求至少要经历改常量、改Key生成规则、评估历史数据兼容、全链路回归、发版。涉及的服务里只要有一处对编号前缀做了硬编码判断还得额外排查。用自增组件时我做的事情就是打开逻辑配置把prefix从ORS-改成SO-把resetStrategy从DAILY改成MONTHLY保存并发布新版本。整个操作不到两分钟没有改一行代码没有动任何服务。这里有个细节值得说引擎会保留逻辑的历史版本。如果业务方觉得新规则不合适想回退直接切回上一个逻辑版本就行比用代码回滚还要快。4. 可靠性核心并发、事务和幂等自增组件底层做了什么4.1 编号重复的经典竞态条件从哪来很多人想当然地觉得取当前最大值加一就行但这个逻辑在并发下一定会踩坑线程A和线程B同时读到当前最大值是100各自加一得到101然后分别落库结果就生成了两个101。这个问题我在生产环境真实遇到过当时线上的表现是有两个订单在同一秒出现在界面上编号一模一样财务对账的时候直接炸了。当时的解决方案很粗暴——加数据库唯一索引重复就重试。但加了唯一索引只是把错误变成了异常重试并没有从根上解决取号和用号之间的原子性问题。4.2 引擎自增组件的防重策略自增组件在设计上绕开了读改写这个非原子操作常规使用的底层机制有三种按可靠性从高到低排序第一种数据库原子自增。利用UPDATE seq_table SET current_value current_value 1 WHERE seq_key ?这种原子的单行更新语句数据库行级锁天然保证并发下只有一个请求能成功更新。更新完再把新值读出来返回整个过程没有竞态窗口。第二种Redis INCR命令。Redis的INCR是单线程执行模型下的原子操作适合高并发场景。但它依赖Redis的持久化配置极端情况下主从切换、AOF重写丢数据存在回退风险所以关键业务不能完全依赖Redis。第三种分布式锁加内存递增。用Redis或ZooKeeper的分布式锁把读取-递增-写回包成一个临界区并发量不高的时候也能用。但锁的粒度和超时时间需要仔细设计否则会成为性能瓶颈。自增组件通常会把第一种或第二种作为默认实现并把第三种作为兜底。**它对外表现就是一个不管你并发多高、只有一个人能拿到这个号的承诺。**开发者不需要自己写锁、不需要理解原子操作、不需要设计Key结构只需要信任这个组件在底层已经把这些事情做好了。4.3 事务边界自增是在业务事务里还是事务外这是一个很多人容易忽略的问题。编号生成是要跟业务数据一起提交事务还是独立于业务事务两种做法各有适用场景。如果编号是在创建订单的事务里生成的那么把自增操作放在同一个事务里可以保证订单创建失败编号也不消耗但会拉长事务时间高并发场景下数据库连接容易被占满。如果编号是在事务外生成的性能更好但会出现号码空洞某个事务回滚了它前面取到的那个编号就永远空下来了流水号从1直接跳到3。业务方经常会问2号去哪了这不是故障而是事务外取得的序列不可回滚造成的正常现象。逻辑引擎自增组件默认的做法是支持这两种模式配置。关键业务对连续编号有严格审计要求时选择事务内取号对性能敏感、不要求连续编号时选择事务外取号。我个人的实践建议是绝大多数业务场景根本不需要连续编号跳号完全可以接受选事务外取号即可性能和灵活性都更好。真正需要连续编号的是发票号、票据号、银行流水这类强监管场景需要单独评估事务边界。4.4 故障恢复与尽可能不重号的冗余设计一个合格的编号生成组件还会做一层冗余防重。即便底层计数器出了问题生成的编号里带上了日期、租户、序号等多个维度发生碰撞的概率已经被压得极低。自增组件本身通常还有一道保险生成的完整编号可以加数据库唯一索引作为兜底。万一底层机制出现意外唯一索引会让重复的写入报错而不是让两条相同编号的数据落库。这层冗余设计在金融、交易场景里尤其重要属于宁可报错不可错账的取舍。5. 实测中总结的边界条件和避坑清单5.1 位数溢出比你想的更常见配置seqLength为4意味着流水范围是0001~9999。我当时想的是一天订单量不可能超过9999结果赶上某次大促加批量数据迁移真的就触顶了。overflowPolicy配置的是THROW_ERROR于是那一整块业务在十分钟内全部报错。后来我把策略改成了EXPAND_DIGITS序号到了9999之后自动变成5位业务没有中断。你现在就可以检查一下自己的配置看看溢出策略到底选的是什么以及这个策略是否和业务预期一致。如果业务要求溢出后当日不接单那THROW_ERROR是对的如果业务要求必须持续可生成那要么用EXPAND_DIGITS要么把位数直接配大一点10位流水号能撑到99亿对绝大多数系统来说一辈子够用。5.2 隔离维度配置不当序号看似自增实则串号自增组件的隔离维度是通过配置项dimensionFields指定的。你需要确保调用逻辑时一定传入了这些字段而且字段值的区分度是真实的。一个坑是从网关传过来的用户标识有些匿名接口拿到的可能是null或anonymous。如果dimensionFields配置了userId而某类匿名请求把userId统一传成了-1那么所有匿名请求共用一个计数器并发高一点就会互相影响。这个不会导致重复编号但会让不同请求的编号在序列维度上粘连审计的时候看着很怪。我在项目里就是这样踩的坑有一个对外公开的申请接口业务上不需要登录调用逻辑时userId字段没有传值组件把它当成空字符串处理。结果所有公开申请的编号全在一个序列里后来我在逻辑入口加了一个默认值节点把uuid作为userId传入才算恢复正常。配置隔离字段之前先想清楚每个调用方到底会传什么值。5.3 和缓存、定时任务配合时的时序问题自增组件依赖计数器存储不管是Redis还是数据库在极端情况下都存在短暂不一致。比如Redis持久化策略用的是RDB发生宕机时可能丢失最近几十秒的递增记录重启后计数器从旧的持久化点继续增长。因为这个机制极端场景下可能生成一个曾经出现过、但因为序列持久化回退而再次出现的号。解决思路不是不用Redis而是在编号末尾加一个随机短码或者对最终编号建立唯一索引。序列本身是否绝对连续、绝对唯一取决于你对可靠性的定义。我的建议是单独一次生成的底层技术选型要匹配业务对不可重复的容忍度该上唯一索引就上不要心存侥幸。5.4 压测时容易误判的几个表现配置化组件的压测结果很可能和你的预期不一致原因主要有三类乐观锁冲突重试底层用数据库原子更新时高并发下锁等待会导致RT升高这不等同于性能上限要区分锁等待和逻辑处理。缓存预热计数器相关的Key第一次被访问时会额外触发一次初始化压测前几百个请求的RT会明显偏高。维度数膨胀隔离字段组合越多计数器Key越多Redis内存和数据库记录量都会增长压测时要观察存储侧的容量水位。判断一个自增组件是否适合你的场景不要只盯着单次RT要看相同并发数下连续生成几万个编号的稳定性和资源消耗。我看到过不少团队压测场景里故意压一个Key导致Redis单Key热点误判组件性能不行其实真实业务里维度分散根本不会打在一个Key上。6. 从自增组件看解耦思维对团队协作的深层影响6.1 配置化之后业务和研发的边界变清晰了自增组件只是逻辑引擎解耦能力的一个缩影。当一套逻辑引擎真正跑起来之后最直观的变化不是代码变少而是业务方和研发方开始用同一种语言对话。以前业务方说把编号前缀改一下研发要翻译成改常量、改测试用例、走发布流程现在业务方可以直接说把逻辑配置里那个前缀字段改一下因为规则本身就是可视化的双方看到的是同一个配置面板。规则变更的决策周期大幅缩短同时因为有版本管理每一次变更都可追溯。6.2 到底什么场景适合逻辑引擎什么不适合根据我自己的使用经验适合用逻辑引擎的典型特征有三个规则变化频繁一年改好几次甚至一个月改好几次调用方多同样的规则要在多个服务里复用规则有明确的可配置结构能拆成参数和节点不适合的场景也值得说清楚算法复杂且性能要求极高比如高频实时风控每一微秒都重要更不建议走额外两层抽象规则本身不稳定到需要经常画全新的流程图而不是改参数这种说明业务还没沉淀出结构团队没有配置管理意识改了配置没有评审、没有测试、没有回滚预案自增组件也一样它不是用来替代所有代码的它要替代的是高频变化的那部分规则实现。6.3 配置化不等于银弹把它用在刀刃上我见过一些团队为了解耦强行引入规则引擎最后把所有代码都塞进流程图里调试起来叫苦不迭。配置化的价值不在用配置代替一切代码而在把频繁变化的规则从稳定的实现里剥出来。编号生成恰好就是这样一个典型场景规则变化频率高、牵涉服务多、并发可靠性要求高、业务影响大。用自增组件来承接它等于把最麻烦的一块切面单独抽出去治理让团队的研发资源集中在真正需要业务深度的地方。如果后续再把条件节点、循环节点、分支节点组合起来还能覆盖更多类似的业务规则场景比如费用计算、状态流转、审批策略等等。至于值不值得引入逻辑引擎我的判断标准一直很简单如果团队一年内有超过三次在改业务规则时动了生产代码并且每次都要全链路回归那配置化这条路就值得认真试试。如果只是偶尔改一两次代码方式反而更直接。工具的价值永远要放到具体的业务上下文里衡量。
返回列表