领域拆分:如何合理地拆分系统?
一般来说,强一致性的系统都会牵扯到“锁争抢”等技术点,有较大的性能瓶颈,而电商时常做秒杀活动,这对系统的要求更高。业内在对电商系统做改造时,通常会从三个方面入手:系统拆分、库存争抢优化、系统隔离优化。
案例背景
为了帮你掌握好系统拆分的技巧,我们来看一个案例。有一次,我受朋友邀请,希望我帮他优化系统。他们是某行业知名电商的供货商,供应链比较长,而且供应品类和规格复杂。为确保生产计划平滑运转,系统还需要调配多个子工厂和材料商的生产排期。原本调配订单需要电话沟通,但这样太过随机。为了保证生产链稳定供货,同时提高协调效率,朋友基于订单预订系统增加了排期协商功能,具体就是将 “排期” 作为下订单主流程里的一个步骤,并将协商出的排期按照日历样式来展示,方便上游供应商和各个工厂以此协调生产周期。
整个供货协商流程如下图所示:
上游项目发布生产计划或采购计划后,供货商会根据这些计划生成采购清单(分单),并联系各工厂进行预排期(预约排期)。接下来,上游采购方对工厂产品进行质量审核,通过后下单支付并确认最终排期。确认排期后,工厂根据排期制定材料采购计划,通知材料供货商分批供货,开始分批生产。每当生产完成一批产品,工厂就会通知物流分批次发货到供货商,同时更新供货商系统中的分批订单状态。接着,采购方对产品验收,发现不合格品则进入退换流程。
系统在实际运行中,原本是以订单为主体设计的,增加排期功能后仍然以主订单为核心,这样一来,每次上游发布计划时都需要创建一个主订单。而随着排期调整频繁、数量不断增加,这些订单数据也在成倍增长。一年内订单数量达到了上亿条,导致系统运行变得缓慢,且由于合作周期长、包含售后环节,这些数据无法通过归档优化。
朋友请我提供建议,我发现问题并不是数据库分表分库能解决的,而是功能设计上不合理。因此,我们决定将订单系统按照业务领域进行拆分,以简化和优化核心业务流程。
流程分析整理
我先梳理了主订单的 API 和流程,从上到下简单绘制了流程和订单系统的关系,如下图所示:
从这张图中可以看出,“订单排期系统”涉及多个角色。通过与产品和研发团队沟通确认后,我确保了对主要流程的数据流向和系统数据依赖关系的理解没有偏差。我们接着把注意力集中在订单表上,发现它承担了过多的职责,这导致多个流程依赖订单表,无法进行有效的数据维护。同时,订单还包含了许多与订单业务无关的状态,比如由于排期周期较长,订单无法关闭。
一个数据实体的职责过多会增加管理难度,因此需要拆分订单和排期的主要职能。分析中,我们还发现系统的核心已不再是订单,而是计划排期。在系统改造前,原订单系统通过自动匹配功能来实现上下游的订单分单,所有模块都围绕订单展开。然而,新增排期功能后,系统核心已从订单匹配转变为基于排期来生成订单的模式,这更符合当前业务需求。排期和订单虽有关联,但职能方向不同:排期是计划,而订单则用于工厂生产、物流和上游验收。这表明系统模块和表的设计核心已发生偏移,因此通过模块拆分可以获得更高的灵活性。
总结来说,我们需要将排期流程和订单交付流程彻底拆分开。在创业公司,项目最初的设计通常会随着市场需求的变化逐渐偏离初衷,因此我们需要不断审视和改进系统,确保其不断优化完善。
为了确保研发团队能够突破原有系统的思维限制,彻底完成拆分并避免改版失败,我对各个角色和流程进行了详细梳理,明确了每个角色的职责及流程间的关系。我根据角色及其操作需求绘制了多个框图,将每个角色需要执行的操作和涉及的数据流整合展示出来,如图所示。
根据这个图,我再次与研发和产品团队沟通,明确了订单与排期在功能和数据上的拆分点。具体而言,上游职能被拆分为:发布进货计划、收货排期、下单、收货/退换;供货商主要负责协调排期分单并提供订单服务;工厂则专注于生产排期、实际生产和售后服务支持。
这样,我们可以将系统流程划分为三个阶段:
- 计划排期协调阶段:这部分不涉及订单,主要是上游和多个工厂之间的排期协调。
- 按排期生产供货 + 周期物流交付阶段:这一阶段需要处理订单,工厂负责按排期生产,物流负责交付。
- 售后服务调换阶段:工厂在此阶段提供售后服务,并处理退换货订单。
由此可见,前期的排期协调阶段无需订单参与,主要是上游与工厂之间的排期安排;而生产供货和售后阶段则依赖于订单,并且各个角色(上游、工厂、物流)在此环节的关注点不同。基于这种流程划分,我们可以按照主要实体和业务流程(以订单ID为聚合根,将流程拆分为订单和排期两个领域)来构建两个子系统:排期调度系统和订单交付系统。
在计划排期协调阶段,上游先在排期调度系统中提交进货计划和收货排期,供货商根据上游的排期和需求,与多家工厂协调分单和议价。多方达成一致后,上游确认计划排期,并预留工厂生产时间。协议签署、支付定金后,排期系统会在订单系统中自动生成对应的订单。这样,一旦上游和工厂确定合作,未来可以在排期系统内持续追加排期,而不会受到单个订单的限制。
在排期生产供货阶段,排期系统在调用订单系统时,会传递主订单号和订单明细,包括计划生产的品类、数量和交付周期。工厂可以据此调整自己的生产排期。生产完成后,工厂按批次将货物发出,并在订单系统记录交付详情,包括交付时间、货物数量和物流信息。订单系统也会生成财务数据,与上游财务和仓库分批对账。
这种拆分不仅能够简化系统结构,还能在各流程中提升数据的可管理性和业务的灵活性。
这么拆分后,两个系统把采购排期和交付批次作为聚合根,进行了数据关联,这样一来,整体的订单流程就简单了很多。总体来讲,前面对业务的梳理都以流程、角色和关键动作这三个元素作为分析的切入点,然后将不同流程划分出不同阶段来归类分析,根据不同阶段拆分出两个业务领域:排期和订单,同时找出两个业务领域的聚合根。经过这样大胆的拆分后,再与产品和研发论证可行性。
系统拆分从表开始
经历了上面的过程,相信你对按流程和阶段拆分实体职责的方法,已经有了一定的感觉,这里我们再用代码和数据库表的视角复盘一下该过程。一般来说,系统功能从表开始拆分,这是最容易实现的路径,因为我们的业务流程往往都会围绕一个主要的实体表运转,并关联多个实体进行交互。在这个案例中,我们将订单表内关于排期的数据和状态做了剥离,拆分之前的代码分层如下图所示:
拆分之后,代码分层变成了这样:
最大的变化是订单实体表的职责被拆分了,使得系统代码更为简洁,也消除了同一订单实体被多个角色频繁交叉调用的情况。在拆分过程中,我们遵循了三个原则:
- 数据实体的核心职能:每个数据实体专注于最核心的职责。比如,订单实体仅处理订单的全生命周期(创建、状态变更、退货、订单完成)等核心功能。
- 业务流程按阶段归类:将业务流程按涉及的实体进行分组,划分为不同阶段,例如“排期协调阶段”、“生产阶段”和“售后服务阶段”。
- 基于数据交互频率的模块划分:如果两个模块在业务流程上交互紧密且数据关联性强(如需要频繁Join,或调用A必然调用B),则将其合并为一个模块,以确保短期内无需进一步拆分。
这种拆分使系统结构更清晰,减少了跨模块调用,同时也增强了各模块在处理特定业务场景时的独立性和灵活性。
如果一个核心系统按实体表的职责进行拆分整理,流程和修改的难度都会显著降低。模块的拆分可以从下往上进行观察,如图6所示。如果模块之间数据交互不频繁,比如没有频繁的Join操作,就可以将系统拆分为四个相对独立的模块。正如图7展示的,这四个模块各自承担了一个核心职能,两个模块之间的交互数据关联较少,每个模块都维护着该阶段所需的所有数据。这样的划分不仅清晰,而且便于集中管理。
在此基础上,我们只需要将数据和流程关系梳理一遍,确保后续统计分析中没有频繁的Join操作,就能完成表的拆分。不过,如果要根据业务划分模块,我建议从上到下梳理业务流程,确定数据实体的划分(即领域模型设计,DDD),从而界定各个模块的职责范围。
越是底层服务越要抽象
除了系统拆分,我们还需关注服务的抽象问题。许多服务因业务细节的变化而频繁修改,尤其是底层服务,更需要减少变更的频率。如果服务的抽象程度不够,一旦底层服务发生变更,我们很难评估该变更对上游系统的影响。因此,我们需要明确哪些服务可以抽象为底层服务,以及如何更好地进行抽象。
以电商系统为例,它经常进行系统拆分和服务抽象。这是因为电商系统中,订单系统是最核心且最复杂的部分。电商商品的品类多样(如SKU和SPU),而不同品类的筛选维度、服务和计量单位各不相同。这就导致系统需要记录大量冗余的品类字段,以保留用户下单时的交易快照。因此,频繁拆分和整理系统是必要的,以防这些独特特性对其他商品产生影响。
此外,电商系统中不同业务的服务流程差异显著。比如,购买食品时,电商只需通知仓库打包、生成物流单、发货和签收;而用户定制柜子时,则需要厂家上门测量、复尺、定做、运输以及后续的调整等。因此,我们需要进行服务抽象,以使业务流程更加标准化和通用,避免频繁变更。
由于业务服务形式存在差异,订单系统需要将其职能控制在“一定范围”内。在满足业务需求的前提下,我们应考虑如何使订单表的数据职能最小化。实际上,这并没有绝对的标准,因为不同行业和公司的业务形态各异。以下是几个常见的抽象思路供你参考:
被动抽象法
如果两个或多个服务共享同一个业务逻辑,那么可以将这个逻辑抽象为公共服务。例如,如果业务A更新了逻辑a,业务B也需要使用这个新的逻辑a,那么就可以把逻辑a提取到一个底层的公共服务中,供两个服务调用。这种方式属于被动抽象,比较常见,尤其适合代码量较小、维护人员有限的系统。在创业初期,系统主脉络尚不清晰时,采用被动抽象法可以方便地进行抽象。
然而,这种方法的缺点在于抽象程度不高。当业务需要频繁变更时,可能需要大规模的重构来适应新的需求。总体来看,尽管这种方式的代码结构贴近业务,但管理起来较为麻烦,且代码分层缺乏规律。因此,被动抽象法更适用于新项目的探索阶段。
里说一个题外话,同层级之间的模块是禁止相互调用的。如果调用了,就需要将两个服务抽象成公共服务,让上层对两个服务进行聚合,如上图中的红 X,拆分后如下图所示:
这么做是为了让系统结构从上到下是一个倒置的树形,保证不会出现引用交叉循环的情况,否则会让项目难以排查问题,难以迭代维护,如果前期有大量这样的调用,当我们做系统改造优化时只能投入大量资源才能解决这个问题。
动态辅助表方式
这种方式适用于规模较大的团队或系统,具体实现如下:当多个开发小组共同使用订单系统时,不同业务创建的主订单会有不同的类型(type)。每种类型会将特有的业务数据存储在不同的辅助表中,例如,普通商品的数据保存在 order
表和order_customize_extra
表中。这样处理的优点在于更贴近业务需求,便于查询。
然而,由于这些辅助表中包含了其他业务的数据,导致业务之间的隔离性较差,所有依赖订单服务的业务可能会相互影响。此外,订单的结构需要时刻跟随业务的改版。因此,通过这种方式抽象出来的订单服务往往显得形同虚设,通常只有企业的核心业务才会采用类似的定制方法。
强制标准接口方式
这种方式在大型企业比较常见,其核心点在于:底层服务只做标准的服务,业务的个性部分都由业务自己完成,比如订单系统只有下单、等待支付、支付成功、发货和收货功能,展示的时候用前端对个性数据和标准订单做聚合。用这种方式抽象出的公共服务订单对业务的耦合性是最小的,业务改版时不需要订单跟随改版,订单服务维护起来更容易。只是上层业务交互起来会很难受,因为需要在本地保存很多附加的信息,并且一些流转要自行实现。不过,从整体来看,对于使用业务多的系统来说,因为业务导致的修改会很少。
通过以上三种方式可以看出,业务的稳定性与服务的抽象程度密切相关。如果底层服务经常发生变更,整个业务也会不断需要修改,最终可能导致业务混乱。因此,我个人推荐使用强制标准接口方式,这也是许多公司的常见做法。尽管这种方式实施起来较为困难,但相比频繁重构整个系统,仍然是一个更可行的选择。
你可能会好奇,为什么不一开始就将第一种方式设计得更完美?这主要是因为大多数初创业务的稳定性较低。虽然提前设计能够保持代码结构的统一性,但经过两年后再回头看,你会发现当初的设计可能已经不再适用。起初信心满满的设计,最终可能会成为业务发展的绊脚石。因此,这种拆分和架构设计需要我们定期回顾、自省和调整。毕竟,技术是为业务服务的,业务才是重中之重。没有人能保证项目初期设计的个人中心不会转变为社交平台的个人门户。
总而言之,没有一种方法是绝对正确的,我们需要根据具体的业务需求来决定采用哪种方式。