前言
之前讨论过活动生命周期,也讨论过怎样通过职责分离控制框架的复杂度。这次把视角放到抽奖活动上,看看当个人操作开始影响共享资源时,框架需要补上哪些边界。
抽奖很容易被理解成一个简单流程:检查条件,确定结果,再发放奖励。只看单次请求,这个过程并不长。
但如果参与者共享有限的奖池,一次操作就同时涉及个人状态和公共状态。再加上活动有开始和结束时间,系统还需要回答:哪些请求仍然有效,哪些资源已经被占用,结束后还有什么工作需要完成?
本文使用虚构的通用抽奖场景,只讨论框架职责和设计取舍,不对应任何具体产品的业务规则、实现结构或运行情况。
先确定变化发生在哪里
假设一个限时抽奖活动,参与者从同一组有限资源中获得结果,并可以在活动结束后查看自己的记录。
这里至少有三种变化。
第一种是活动时间变化。相同的操作,在不同阶段可能有不同的可用性。
第二种是个人状态变化。一个人是否满足条件、是否已经领取,与其他人的状态不完全相同。
第三种是共享资源变化。一个人成功获得某项资源,可能影响其他人后续能够获得的结果。
如果把这些变化全部交给抽奖入口处理,入口就需要同时理解周期、规则、库存和奖励。以后调整其中一个方面,也很容易牵动整个流程。
所以设计框架时,我会先明确每种状态由谁负责,再考虑怎样组织抽奖过程。
活动框架负责共性,抽奖模块负责规则
开始时间、访问条件、周期切换和历史清理,并不是抽奖活动独有的问题。如果已经存在活动框架,这些能力应该继续由它统一管理。
抽奖模块接收到的,应当是一份已经明确身份和时间边界的活动上下文,然后在这个范围内执行自己的规则。
| 职责 | 需要回答的问题 |
|---|---|
| 活动生命周期 | 当前是哪一期,处于什么阶段,允许哪些行为? |
| 抽奖业务编排 | 本次操作需要哪些条件,哪些步骤必须按顺序完成? |
| 共享资源管理 | 还有哪些资源可用,本次占用是否与其他操作冲突? |
| 个人状态管理 | 当前参与者有哪些记录,哪些行为已经完成? |
| 奖励处理 | 如何把已确定的业务结果转换成实际权益? |
这是一张职责表,不要求每一行都拆成独立服务。
拆分的意义在于,让规则变化停留在合适的位置。例如调整某类参与条件,不应该顺带重写活动时间判断;调整共享资源的存取方式,也不应该让入口重新认识底层数据结构。
复用同样需要边界。共同流程可以统一,只有某一种活动才有的规则,应该留在它自己的扩展部分。为了容纳一个特殊需求而给所有活动增加状态,往往会让公共框架越来越难理解。
个人状态与共享状态要分开理解
个人记录通常可以按参与者隔离;共享资源则需要让所有相关操作看到同一个约束。
这两个范围不能混用。
即使同一个人的请求始终按顺序处理,不同的人仍然可能同时争夺最后一份资源。个人请求有序,并不能保证公共库存不会被重复分配。
反过来,如果所有个人查询都必须经过一个覆盖整个奖池的串行入口,原本互不影响的操作又会被无谓地绑在一起。
设计时可以先明确三个概念:
- 活动实例:区分一次运行与下一次运行,不能只靠反复复用的活动名称。
- 共享范围:明确哪些参与者共同使用同一份资源。
- 个人身份:明确一份记录归属于谁。
它们共同确定数据的归属,但不意味着所有数据都应该保存在一起。
反过来,归属于个人的数据也不一定都能和共享资源分开存放。如果某项个人数据本身就是共享约束的一部分,例如“每人最多获得一份稀有资源”所依赖的资格记录,它就必须和共享资源处在同一个原子边界内,否则检查和占用之间仍然会出现空隙。
共享范围应由服务端根据可信上下文确定。请求可以表达想参加哪个活动,但不能自行决定自己属于哪个共享池。
周期身份也应该保持稳定。调整一个时间字段是否代表新一期,需要有明确规则,不能让一次普通时间调整意外变成状态重置。
把结果准备与请求处理分开考虑
一个通用的抽奖请求,可以先用下面的职责关系理解:
参与请求
↓
确认活动上下文与操作条件
↓
取得有效结果,并确认共享资源归属
↓
处理个人权益与记录
↓
返回本次结果和相关状态
这张图表达处理依赖,不表示整条链路天然处于同一个事务中。
对于结果准备成本较高、规则又允许提前计算的场景,可以考虑在请求之外准备有限数量的候选结果。请求到来时,再根据当前状态确认是否能够使用。
这样做的目的,是减少请求过程中重复承担的准备工作。但它有一个前提:提前准备不能改变原有规则,也不能把已经失效的结果继续交给参与者。
提前准备不等于已经发放
引入缓冲以后,资源至少存在几种不同含义:
| 状态 | 含义 |
|---|---|
| 尚未占用 | 仍然可以用于后续分配 |
| 已为待处理结果预留 | 暂时不能重复分配,但尚未形成最终归属 |
| 已确认归属 | 已经形成一次有效的业务结果 |
如果把预留直接当成已经发放,对外展示会失真;如果预留完全不计入资源约束,又可能让多次准备重复占用同一份库存。
框架需要说明这些状态怎样转换,以及准备失败、结果未被使用或活动关闭时,哪些资源仍然可以释放。
缓冲不是越多越好
缓冲太少,准备过程可能频繁进入请求路径;缓冲太多,则会增加资源预留、状态维护和规则调整的成本。
因此它应该有明确的容量边界和补充条件。多个执行者同时发现不足时,也不能各自认为自己拥有补充权限。
尤其需要注意:一次准备任务开始时有效,不代表它提交结果时仍然有效。期间可能发生关闭、取消或执行权转移,提交时必须重新确认当前约束。
如果准备过程依赖大量即时个人状态,或者计算本身很轻,就不一定适合提前准备。是否采用缓冲,应由规则和测量结果决定,不能仅凭“提前算好会更快”作判断。
原子边界要围绕共享约束划分
共享资源竞争中,一个典型问题是“先读取,再判断,最后写入”。多个请求可能同时读到资源充足,随后各自通过检查。
因此,与同一次共享状态转换直接相关的检查和修改,需要在一个不可被其他操作插入的边界内完成。
这里应该先明确必须共同成立的约束,例如资源不能超额分配,同一份待处理结果不能被重复消费。再根据这些约束选择存储能力,而不是先选择一种技术,然后把所有业务都塞进去。
如果底层存储存在分片,设计时还需要确认:这些必须一起变化的数据,是否能够落在同一个原子操作范围内。分片方式与一致性边界有关,不能等功能写完后再补。
这个边界应该尽量短。外部资料查询、权益发放和其他远程调用,通常不适合占据共享资源的临界区。
局部原子不等于整体事务
共享状态更新成功,并不能自动证明个人权益已经保存;个人权益处理成功,也不能自动证明客户端收到了响应。
这些是不同的完成事实。
框架需要区分“已经确定失败”和“暂时不知道是否成功”。前者可以在明确条件下执行对应的回退;后者不能直接重新执行一次,也不能贸然回退;如果系统没有可靠的方式确认原操作结果,就应保留现场信息,交给对账或人工核查处理。
回退本身也有前提。它应针对明确的一次操作,并且有清楚的执行次数和状态约束。仅仅存在一个回退入口,不足以说明进程退出、网络中断和跨存储失败都已经得到处理。
对一致性能力的描述,应当止于真正能够保证的边界。
结束活动也是一条业务流程
活动结束不能只理解为“页面不再展示”。共享资源仍然可能处在准备、预留或处理中的状态,后台也可能还有未完成的工作。
在一个允许结束后查询的通用模型里,可以区分:
- 接受新的参与操作。
- 停止新增操作,处理尚未完成的收尾工作。
- 保留结果查询和已达成奖励的领取,等待历史数据清理。
这些阶段允许的行为不同,所以参与截止、查询截止和数据删除,不应被当成同一个时间点。
关闭时尤其要考虑正在执行的准备任务。仅仅在入口处拒绝新请求还不够,旧任务也需要在提交时识别活动已经关闭,否则资源可能在收尾之后再次发生变化。
收尾流程应由生命周期调度驱动。不能假定截止后还会有人访问页面,再依靠那次访问触发公共工作。
重启以后,靠什么继续
内存中的定时任务只能说明当前进程安排过工作,不能证明工作已经完成。
因此,需要分别理解三件事:任务是否已安排、业务是否正在处理、处理结果是否已确认。恢复时依据当前周期和已有事实判断下一步,而不是单纯重放之前的所有动作。
已经开始的收尾工作是否可以继续、重复执行会不会改变结果、哪些步骤需要人工核查,都应该有明确约定。
同样,进程内防重只能约束本进程。若未来允许多个执行者共同负责同一项工作,就需要重新审视执行权和重复处理的边界,不能默认原来的保护仍然成立。
查询与性能优化保持各自的边界
查询侧应该围绕用户需要看到的业务事实组织结果,而不是直接暴露内部协调状态。
例如,展示已确认结果时,不应该把内部预留也算进去;个人记录、共享概况和公开展示资料,也不一定需要使用同一种读取方式。
独立资料可以批量获取或并发读取,重复依赖可以在一次请求内复用。但不同数据是否必须来自同一时刻,需要根据用途判断:允许短暂变化的展示信息,与用于分配资源的校验条件,要求并不相同。
分页同样要说明语义。实时变化的列表未必是固定快照,能分批读取,不代表翻页期间内容不会移动。
性能评估则应覆盖整个操作过程,包括准备结果、竞争共享资源、处理个人数据以及返回响应。缩短其中一个步骤,只能说明这个步骤发生了变化,不能直接推出整体吞吐提升。
在没有测量之前,更稳妥的说法是:职责拆分减少了哪些重复工作,缩小了哪些串行范围,以及新增了哪些状态维护成本。
回头看,抽奖框架需要处理的远不止结果计算。活动周期决定行为何时有效,状态归属决定数据由谁负责,共享约束决定哪些步骤必须一起完成,失败边界决定系统能够承诺什么。
这些问题越清楚,后续增加规则时,需要重新理解的范围就越小。框架的价值,也就体现在每次变化都有明确的落点。