ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

可扩展性设计实战:如何让系统不被需求拍死

可扩展性设计实战:如何让系统不被需求拍死 最近来找我聊“系统设计”需求的朋友背景特别杂有做流浪动物领养管理系统的有做设备运维工单系统的有拿51单片机做倒车雷达的还有在用RK3588做实时视频监控的。话题千差万别但落到最后几乎都是同一个问题系统怎么做才不会被下一个需求“拍死”也就是大家常说的可扩展性和系统设计。网上讲可扩展的文章不少但很多要么堆了一堆架构名词要么只拿电商、社交这种大系统举例离实际工作太远。我这些年做过业务后台也折腾过嵌入式设备慢慢发现可扩展性并不是一个高深的技术指标而是一套可以跨领域复用的设计习惯。这篇就把我平时设计系统的思路、判断标准还有真实踩过的坑完整写出来。正在做系统设计论文、或者维护中小型项目的朋友可以参考一下。1. 先搞清楚“可扩展”到底在扩展什么需求维度的拆解1.1 可扩展不是“堆功能”而是“接得住未知变化”很多朋友一提到可扩展第一反应就是“把以后可能用到的功能都先做上”。比如做办公用品申领系统时非把预算、库存、多级审批、采购计划全塞进第一个版本结果开发周期拖了三个月上线后一大半功能没人用。这其实是把可扩展性和功能丰富度搞混了。真正的可扩展性指的是当新需求真的出现时你不需要推翻已经稳定运行的模块。它关心的是“未来变化的冲击能不能被局部吸收”。打个比方流浪动物领养管理系统今天只支持“发布领养信息”下个月要增加“领养回访记录”。如果新增这个功能只需要在领养单旁边挂一张子表、在页面加一个Tab完全不用去碰领养状态机那这个系统在功能扩展维度就是合格的。有一个很朴素的比喻可扩展性像装修方案而不是家具清单。你不需要提前买好五年后才会用的柜子但要在墙里预留够插座、留好网线管道让五年后买来的电器能直接插上。软件里的“插座”就是接口、配置项和数据模型上的冗余空间。1.2 先分清四种扩展方向容量、功能、形态、团队可扩展这个话题容易让人焦虑就是因为“扩展”这个词太宽泛。你不可能让一个系统在四个方向上都做到极致第一步应该是搞清楚自己正在做的系统最可能面对哪类扩展。扩展方向典型表现常见例子容量扩展数据量、并发量、设备数量增长车辆出入管理系统从1个门岗扩到8个门岗工单系统从几十人用到几千人功能扩展新增业务能力抢答器PLC控制系统从支持4组选手扩到10组还要支持多种违例判定规则形态扩展系统运行形态变化监控系统从单机跑到上云从PC网页接到小程序团队扩展更多开发者共同维护扩展模块边界清晰与否直接影响多人协作效率拿热搜里的这些系统来说“基于springbootvue的社区老年服务管理系统”这类业务系统最需要关注的是功能扩展“基于51单片机的倒车雷达报警系统”更多是传感器类型、报警策略等功能变化“基于RK3588硬编码的实时视频监控系统”涉及路数增加、编码格式变化是典型容量加形态扩展。方向不同设计重心完全不同。所以千万别用一个“万能架构”去满足所有扩展。给51单片机倒车雷达系统上分布式微服务听起来很先进实际上是灾难。我在实际设计前会先列一张简表确认这个系统未来半年内最可能出现的三到五个变化点然后只围绕这些变化点做设计其余部分保持朴素。2. 模块边界80%的扩展性死在“看似合理的分层”里2.1 分层的诱惑与陷阱网上教学项目最常见的结构就是三层Controller、Service、DAO。很多开发者也默认“我分了层所以可扩展”。但实际项目里分层只是把代码按文件夹归了个类并没有真正形成模块边界。问题出在哪拿基于djangovue的流浪动物领养管理系统举例。代码分成了views、models、templates宠物模块和领养模块各有model看起来清清楚楚。过了一阵子需求要加“爱心助养”用户不直接领养而是按月资助某只流浪动物的生活费。这时候该怎么扩展很多人的做法是在宠物表加一个 is_supported 字段再往领养订单表里塞一条“类型为助养”的记录……慢慢就把宠物模块和领养模块搅在一起了。真正可扩展的分层不只按文件分层而是每一层都提供清晰的“语义接口”。上层调用时只关心能力不关心实现细节。比如宠物模块暴露“宠物档案查询”“宠物状态变更”领养模块暴露“提交领养申请”“审批领养申请”两者之间只通过接口交互而不是互相直接查对方的数据库表。2.2 高内聚、低耦合的操作性判断标准高内聚低耦合听起来很教科书但落地其实可以很简单。我自己常用的判断标准是如果要修改一个业务规则需要同步改动的文件数量是多少。以办公用品申领系统为例。初始需求是“员工提交申领单部门主管审批管理员发货”。假设现在要加一条规则库存低于10件时提交前提示管理员但允许员工继续提交。如果这个改动只需要改“库存查询”相关的一个服务类说明库存模块内聚性不错如果得改员工表结构、改申领单状态、改前端表单、再改消息推送那就是耦合太高了。另外一个判断标准叫“共同封闭原则”应该把那些因为同一个原因而变化的东西放在一起把因为不同原因而变化的东西分开。用大白话说两个文件如果常常因为同一个需求一起修改那它们应该在同一模块如果一个需求到来时只有某个文件变化其他文件都不动这个边界就是对的。很多扩展性差的项目根源不是不懂设计模式而是从不检查“需求变化时波及面有多大”。我每轮迭代后都会做一次小回顾最近加的三个需求每个实际改动了哪些代码如果改动范围一次比一次大模块边界已经腐烂了得赶快找到那个被反复穿透的“防火墙”。2.3 依赖方向是一条“单向通道”模块化设计里还有一条容易被忽略的规则依赖方向必须统一指向稳定层。什么是稳定层业务规则、核心状态机、对外接口语义。什么是不稳定层具体厂商SDK、具体数据库表结构、第三方服务。规则很简单不稳定层可以依赖稳定层稳定层不能反过来依赖不稳定层。用RK3588视频监控系统举例。项目里用了某家摄像头厂商的SDK如果核心业务逻辑直接调用这些函数厂商升级协议或者你要换另一个品牌摄像头就只能把所有调用点全部翻一遍。正确做法是定义自己的“摄像头适配接口”把厂商SDK封装在驱动器里上层业务只与适配接口打交道。以后接新摄像头只是新增一个适配器核心系统完全不动。这就是“依赖倒置”的通俗版本。它不是Java或Spring专属概念嵌入式系统、PLC控制、单片机项目同样适用。FFT频谱分析里的算法模块不应该直接依赖某个ADC芯片的寄存器操作抢答器的逻辑也不该把计时器硬件细节散落在业务代码里。稳定锚点是“采集到有效数据、算出频谱、驱动显示”至于数据从哪个设备来应该被塞进依赖链的最底层。3. 一套法则两个世界从工单系统到嵌入式控制3.1 找到系统里的“稳定锚点”可扩展性设计的第一步不是画架构图而是识别系统里什么是“几乎不会变”的东西。我把它叫稳定锚点。稳定锚点通常来自业务本身的领域特征而不是技术。拿设备运维工单系统来说“工单”这个概念几乎不会消失。它可以叫维修单、保养单、巡检单但本质上都是一个“需要被记录、流转、完结的任务”。工单状态也相对稳定待派单、处理中、已完成、已取消。这个稳定的状态机是整个系统的骨架。围绕骨架你可以自由扩展通知方式、SLA计算、附件类型、回访流程但状态机的核心流转不会轻易变。嵌入式领域也一样。双容水箱液位控制系统不管用PID、模糊控制还是模型预测控制要做的都是“采集液位→运算→调节阀门→保持液位稳定”。这个采样-处理-输出的闭环就是稳定锚点。控制算法不稳定但数据流架构是稳定的。找到稳定锚点之后就有了设计方向把频繁变化的东西往锚点外围放让它们像插件一样插在骨架上。很多人在做系统设计论文时忽略这一步直接开始画用例图、类图结果设计出来的只是数据库表单的罗列没有真正的稳定边界。3.2 不稳定部分放进“策略”和“插件”稳定锚点确定后要处理的就是“不确定但可能变化”的部分。最实用的两个工具是策略模式和插件化设计。策略模式的本质是定义一套统一行为接口让不同实现类完成同一件事的不同版本。不要觉得策略模式是面向对象语言专属。在51单片机倒车雷达系统里同样可以用定义报警策略接口当前可能是“距离小于1米连续蜂鸣小于0.5米转急促”以后要支持不同报警声音、不同传感器非线性度只需要新增策略函数再用函数指针数组管理。这就是嵌入式C语言里的策略模式只是表现形式不同。插件化设计听起来高级实际落实起来也不复杂在系统里设立“扩展点”允许通过注册、配置或事件订阅接入新模块。工单系统可以留一个“派单扩展点”默认轮流派单后来想按技能匹配、按区域匹配、按忙闲度匹配都只要新增派单策略实现不用改工单主流程。事件也是一种插件机制。运维工单结算以后发布“工单已结算”事件审计、绩效、短信模块各自订阅自己关心的部分。3.3 从两个场景看落地方式的差异总有人问你说的这些都是软件系统玩法嵌入式到底怎么落地其实换一个角度就行。以“基于RK3588硬编码的实时视频监控系统”为例稳定锚点是“视频采集→编码→推流→显示”。不稳定的是输入源协议RTSP、RTMP、私有SDK和编码参数H.264/H.265、码率、帧率。设计上输入源被抽象成“源适配器”编码参数抽成配置文件。新增摄像头只增加适配器调整画质只改配置不动核心。再看“输送线多级传送带控制系统”。稳定锚点是一级一级传送带之间的“物料交接”逻辑不稳定的是每一级的速度、启停顺序、是否带产品检测。如果所有定时器、电机控制代码写在一个主循环里以后增加一级传送带就要改主循环。正确做法是把每一级传送带看成一个独立模块通过统一接口上报状态、接收命令由主控制器编排交接逻辑。复杂度从“乱成一团”变成“线性增加”。这说明可扩展性法则不区分软件和硬件它最终都在做同一件事把变化集中到某个边界内让其余部分保持稳定。这是我在不同项目里最有体会的一点。4. 数据模型与接口契约扩展的关键支点4.1 数据模型决定了扩展的天花板业务系统里数据模型的影响往往比代码结构更深远。代码可以随时重构生产数据一旦成型迁移成本极高。我见过不少系统扩展困难根子都在第一版表结构设计得太“死”。一个典型反例就是把业务对象直接做成字段。比如办公用品申领系统第一版建了一张申领表里面有签字笔数量、A4纸数量两个字段。看起来直接但业务一扩展就完蛋增加“文件夹”要改表增加“硒鼓”要改表统计“总申领金额”时还得把所有字段列一遍。正确设计是先把物品抽成目录再通过申领明细记录“申领单号物品ID数量”这就是经典的“单证-明细”模式。除了单证明细还有几个建模习惯对扩展性特别有帮助。一是尽量用状态字段而不是多个布尔字段。领养订单如果设计成 is_adopted、is_returned、is_archived 三个布尔值四个状态以后就分不清了不如用一个 status 字段加状态机。二是允许差异部分用扩展属性JSON。不同申领单可能有不同附加信息比如“电脑申领”需要填配置单号“办公用品申领”需要填用途。在主表上放一个 ext_json既避免不停加字段也避免过度抽象导致每个字段都是空值。但注意JSON里的字段只能用于辅助查询不能作为核心业务判断否则数据质量和可维护性都会变差。嵌入式系统也有数据模型问题只是表现不同。双容水箱系统如果PID参数、液位上下限、采样周期都写死在源码里调试时就得反复烧录程序。把参数放到EEPROM或参数表运行时动态读取就等于给“模型”加了扩展维度。可扩展性从来不是业务软件专属名词。4.2 接口契约比内部实现更值得花时间模块之间的接口是整个系统最容易“牵一发而动全身”的地方。内部实现写得不够优雅顶多维护困难接口一旦定义不好多方依赖会让系统变得异常脆弱。我在设计接口时有几条硬规矩。第一接口语义面向业务而不是面向数据表。“获取工单详情”“提交领养申请”是业务语义“执行selectById”或“往表里insert一条记录”是数据操作。接口面向业务才能允许内部数据结构自由调整。第二对外接口要尽量兼容演进。新增字段时不要用“必填”避免老客户端无法调用能用可选version参数就用版本号。很多第三方集成方一直在调用老接口如果你的接口升级直接删掉旧字段对方第二天就会报警。接口设计要像合同补充条款一样只增不删除非能确认所有调用方都已迁移。第三把事件当成最高级的扩展点。以设备运维工单系统为例工单状态变成“已完成”时可以发布一个“工单完成事件”。之后要通知客户、生成结算单、更新设备档案、推送满意度问卷全部通过订阅事件实现主流程完全不动。这是扩展性最好的一种方式但也要约定事件结构避免各个订阅者各取所需造成隐性耦合。4.3 配置化小事别写死在代码里很多时候扩展需求只是一些参数变化并不需要新增代码。工单系统的SLA超时阈值、倒车雷达的报警距离、社区老年服务管理系统的服务项目价格、申领系统的审批层级都可以放到配置文件或数据库配置表里。系统一旦支持运行时调整参数就等于多了一层灵活度。但配置化要克制。配置项越多系统的可预测性和可测试性就越差。一个配置如果从来没有被调整过那它就是变相的代码却还失去了编译期校验。我的原则是同一类参数统一放一块给出默认值只有那些业务上确实会频繁变化的内容才做成配置变化可能性低的内容宁可写在代码里。高并发场景下动态读取配置也会带来一致性和性能问题这些问题同样需要在引入配置化之前想清楚。5. 一个完整落地案例从办公用品申领系统的三次演进说起5.1 初始版本宁可多做一张表也别把物品塞进字段假设第一版需求很简单员工提交办公用品申领单部门主管审批管理员发货。表结构建议这样设计申领单主表id、申请人id、部门id、总金额、状态、备注、创建时间、审核时间、发货时间申领单明细表id、申领单id、物品id、数量、单价、小计物品目录表id、名称、规格、当前库存、可用库存、计量单位这里有几个设计细节要特别注意。金额字段用整数存“分”不要用浮点存“元”数量存储统一用最小单位展示层再做转换。这些可以避免后面的对账出现0.10.2这类浮点问题。接口方面我一开始就定义了一个计算方法获取可申领数量itemId, userId, deptId。第一版实现里它可能只是返回库存但接口语义是面向业务能力的。以后要接预算规则时只需要在实现里追加逻辑不改变调用方。这就是最早埋下的扩展点。5.2 第一次演进部门预算校验两个月后财务提需求每个部门每个月有额度申领金额不能超过部门当月剩余预算。这个需求到来时因为前面已经有了“获取可申领数量”的语义接口不需要改员工页面只需要在这个接口的实现里追加预算校验逻辑。真正复杂的地方在于审批通过时要占用预算所以又新增了一张预算账本表部门id、月份、预算总额、已占用金额。审批通过事件发布后一个订阅者去占用预算审批取消时再释放预算。主流程只发事件完全不知道预算模块的存在。这个案例正好说明事件的作用如果没有事件机制这里就得在审批通过的业务代码里手写预算逻辑每加一个新需求主流程就被塞得越来越胖。有了事件预算模块变成独立订阅者可以独立测试、独立演进。5.3 第二次演进库存联动与采购建议再后来仓库管理员希望申领单“已发货”时自动扣库存库存低于阈值时自动生成采购建议单。因为已经有了“已发货”这个状态变更事件这次只需要新增一个库存订阅者。扣库存的动作写在库存模块内部不污染核心流程。采购建议则由库存模块内部再发一个“库存不足”事件采购模块订阅后生成采购单。两个模块之间没有直接方法调用只通过事件解耦。对于单体系统事件表往往比消息队列更好维护至少还能在数据库里查到事件记录方便排查问题。这一阶段还涉及一个业务决策扣库存是“下单即扣”还是“发货即扣”这个决定比技术选型重要得多。我们最终选了“发货即扣”因为允许审批通过后暂不发货而库存应该反映真实可发货数量。这也是为什么需要事件而不是简单地同步调用——不仅是为了解耦也是为了在不同业务节点都能触发一致的动作。5.4 第三次演进多类型审批流与委托代办最后公司规模变大不同物品需要走不同审批流程。普通文具部门主管审批就行电脑和手机要部门主管加行政负责人两级审批部门主管出差时可以委托给副主管。这次演进改动最大的是“审批流引擎”。我并没有改数据模型而是在审批记录表里增加了一个“审批节点”字段又新增了一张“审批流程配置表”配置项包括物品类型、部门层级、是否允许委托。流程引擎读取配置后生成待办推送模块订阅“待办生成”事件把消息推给钉钉或邮件。最值得强调的是三次演进下来申领单主表的结构几乎没有变化。物品目录表、申领单明细表、状态字段、事件机制撑住了所有变化。新增的内容都是表、模块和配置而不是修改旧的稳定模型。这就是我理解的扩展设计增量修改为主存量修改很少。5.5 实际取舍别急着上微服务其实这个系统完全可以单应用跑很多年。如果一开始为了“可扩展”就上微服务拆分成库存服务、预算服务、审批服务、消息服务部署复杂度和分布式事务难度会把这套小系统直接压垮。扩展性不等于分布性更不等于微服务。微服务只是“团队级可扩展”的一种实现手段不是所有系统的默认答案。我见过太多团队为了“以后会很大”而上微服务结果联调时要协调三个服务也见过单体应用因为模块边界清晰、事件解耦到位运营五年依然轻松支撑新需求。对中小型业务系统而言模块化单体加事件加清晰接口往往比微服务更划算。6. 可扩展性之外那些没人提前告诉你的代价6.1 扩展点本身是有代价的每引入一个抽象、一个事件、一个配置项都会给系统增加认知负担和排查难度。事件处理会让调用链不直观一个业务动作在代码层面没有直接调用而是通过事件触发出了问题要靠事件记录来还原现场。没有日志和监控的事件机制比同步调用更可怕。所以设计可扩展系统之前先问自己这个扩展点今天是否真的必要如果需求还没被验证不如先写成最简单、最直接的代码。可扩展性设计最容易犯的错误就是把“拥抱变化”变成“预支复杂度”。抽象的正确时机是在同一种变化出现两次之后而不是在它第一次出现之前。6.2 用“修改成本”指导设计而不是“代码行数”我现在评估一个系统设计好不好不再看代码写得多优雅、用了多少设计模式而是看“新需求平均修改成本”。如果一个系统连续三次添加功能都不需要动旧代码只在旁边新加文件和配置那它的扩展性就很好如果每次加功能都要改核心状态机、改数据库表、把所有调用点翻一遍再漂亮的设计模式也救不了它。6.3 从小系统开始用演进代替一步到位对大多数正在做毕业设计或内部工具的朋友我的建议很简单第一版用最简方案但保留一个清晰的模块骨架把稳定锚点找出来每个版本结束做一次改动波及面回顾当发现扩展点不够用时再针对性地引入策略模式、事件、配置化。可扩展性从来不是一次性设计完成的而是在一次次演进中长出来的。最后再分享一个我自己的习惯每次接新需求我会先在代码注释里写一段“本次需求改动了哪些模块、有没有用到旧的扩展点”当作扩展性体检报告。坚持半年之后你会比任何架构评审专家都清楚自己系统的病灶在哪里。这个方法不花时间但对维持一个系统的长期健康非常有效。
返回列表