news 2026/10/17 2:41:15

SaaS套餐怎么设计?能力项、资源配额、版本与租户订阅的建模方法

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SaaS套餐怎么设计?能力项、资源配额、版本与租户订阅的建模方法

一个 SaaS 系统刚上线时,常见做法是在租户表上放plan_type,再用if (plan_type == PRO)判断能不能使用批量报表。只要套餐永远不变,这样似乎够用。但当平台把专业版每月报表任务上限从 20 次调整为 30 次时,问题就来了:甲公司已经订阅的旧版本是否立即变成 30 次?客服如何解释昨天和今天看到的权益不同?乙公司没有报表能力,却有同名菜单权限,又该以谁为准?

这篇讨论的不是定价策略,而是套餐定义如何变成可解释的租户权益。示例使用虚构数据,环境为 Java 17、Spring Boot 风格服务层、MySQL 8.0.16 及以上;不涉及实际产品的套餐、价格或客户配置。

目录

  1. 先确定一次权益争议的输入和预期
  2. 套餐模板、订阅、能力和配额分别负责什么
  3. 数据模型:让版本和来源可以追溯
  4. 如何解析某一时刻的有效权益
  5. 用测试和 SQL 证明结果没有漂移
  6. 边界、上线验收与延伸阅读

一、先确定一次权益争议的输入和预期

沿用前文的虚构租户:甲公司t_a、乙公司t_b。平台在 2026-10-01 发布专业版第 3 版PRO v3,其中batch_report已开通,每自然月最多创建 20 个报表任务,存储上限为 100 GiB。甲公司订阅sub_101指向这个具体版本,有效区间为[2026-10-01 00:00, 2026-11-01 00:00)。乙公司订阅基础版,没有batch_report。

10 月 16 日平台发布PRO v4,把示例报表上限改为 30。此时查询甲公司,预期仍是PRO v3 / 20 次;只有一条明确的订阅变更记录把它指向v4,它的权益才会改变。这里的数字只用于说明版本绑定,并非真实产品配额。

查询对象与时间套餐来源报表能力报表上限应如何解释
t_a,10 月 15 日sub_101 → PRO v3开通每月 20 次当时仅有 v3
t_a,10 月 16 日sub_101 → PRO v3开通每月 20 次发布 v4 不改旧订阅
t_b,10 月 16 日sub_201 → BASIC v1未开通无菜单权限不能补出套餐能力
t_a,11 月 1 日 00:00无覆盖的有效订阅不可判定为开通无区间右端不包含在内

最后一行故意没有假设自动续订。系统若有续订,应产生可追溯的新订阅或新有效区间;不能在权益解析失败时回退成“默认专业版”。

图1:发布新套餐版本与变更租户实际订阅是两件事;查询结果必须带来源版本。

二、套餐模板、订阅、能力和配额分别负责什么

最容易混淆的是“产品能提供什么”“租户买到了什么”“某个用户能做什么”三个问题。套餐模板描述候选产品组合;租户订阅说明一个租户在什么时间采用哪个不可变版本;能力项回答功能是否可用;配额给出允许的资源边界。成员角色仍决定个人动作权限,不能被套餐替代。

对象保存什么不保存什么
套餐版本套餐编码、版本号、发布状态某租户已经用了多少资源
能力项batch_report等稳定标识、是否开通个人的按钮权限
配额项指标键、上限、单位、统计周期实际用量和剩余额度
租户订阅租户、套餐版本、有效时间、订阅状态一份可随模板编辑而变化的模糊引用
用量事实某周期已使用或已预留的量套餐原始定义

把能力和配额拆开有实际价值。例如“可以上传文件”是能力,“总存储不超过 100 GiB”是配额;“可运行批量报表”是能力,“每自然月最多创建 20 个任务”是配额。没有开通能力时,上限即使残留也不能授权执行;上限没有配置时,也不能擅自理解为无限。是否允许无限额,应使用显式策略,而不是以NULL猜测。

版本号也不能只是页面展示字段。PRO v3发布后,能力和配额明细要保持不可变;调配额应创建v4。可以把“停止新售”和“旧订阅失效”分成两种动作:前者禁止新订阅引用旧版本,后者涉及存量客户权益,不能通过修改模板状态偷偷完成。本文采用应用层发布校验与变更权限控制来保证不可变,数据库外键只保证引用关系,不能自动阻止管理员直接改动已发布的明细。

图2:配额上限来自套餐,用量来自运行事实;套餐能力与个人权限也不是一回事。

三、数据模型:让版本和来源可以追溯

下面是一个刻意收窄的模型:只表达套餐与有效订阅,不把计费、优惠、续费或资源扣减混进来。时间统一存储为 UTC,接口按业务时区展示;[starts_at, ends_at)避免相邻区间在边界时刻同时生效。真实系统应有租户主表,这里只保留用于锁定订阅变更的最小租户行。

CREATETABLEdemo_tenant(tenant_idVARCHAR(32)PRIMARYKEY)ENGINE=InnoDB;CREATETABLEdemo_plan_version(plan_codeVARCHAR(32)NOTNULL,version_noINTNOTNULL,sale_stateVARCHAR(16)NOTNULL,published_atDATETIME(6)NOTNULL,PRIMARYKEY(plan_code,version_no),CONSTRAINTck_plan_versionCHECK(version_no>0),CONSTRAINTck_plan_saleCHECK(sale_stateIN('ON_SALE','CLOSED')))ENGINE=InnoDB;CREATETABLEdemo_plan_feature(plan_codeVARCHAR(32)NOTNULL,version_noINTNOTNULL,feature_keyVARCHAR(64)NOTNULL,enabledBOOLEANNOTNULL,PRIMARYKEY(plan_code,version_no,feature_key),CONSTRAINTfk_feature_planFOREIGNKEY(plan_code,version_no)REFERENCESdemo_plan_version(plan_code,version_no))ENGINE=InnoDB;CREATETABLEdemo_plan_quota(plan_codeVARCHAR(32)NOTNULL,version_noINTNOTNULL,quota_keyVARCHAR(64)NOTNULL,limit_valueBIGINTNOTNULL,unit_codeVARCHAR(24)NOTNULL,period_codeVARCHAR(24)NOTNULL,PRIMARYKEY(plan_code,version_no,quota_key),CONSTRAINTck_quota_limitCHECK(limit_value>=0),CONSTRAINTfk_quota_planFOREIGNKEY(plan_code,version_no)REFERENCESdemo_plan_version(plan_code,version_no))ENGINE=InnoDB;CREATETABLEdemo_subscription(subscription_idVARCHAR(32)PRIMARYKEY,tenant_idVARCHAR(32)NOTNULL,plan_codeVARCHAR(32)NOTNULL,version_noINTNOTNULL,starts_atDATETIME(6)NOTNULL,ends_atDATETIME(6)NOTNULL,sub_stateVARCHAR(16)NOTNULL,revisionBIGINTNOTNULL,CONSTRAINTuq_tenant_startUNIQUE(tenant_id,starts_at),INDEXix_sub_cover(tenant_id,sub_state,starts_at,ends_at),CONSTRAINTck_sub_intervalCHECK(starts_at<ends_at),CONSTRAINTck_sub_stateCHECK(sub_stateIN('ACTIVE','CANCELLED')),CONSTRAINTfk_sub_tenantFOREIGNKEY(tenant_id)REFERENCESdemo_tenant(tenant_id),CONSTRAINTfk_sub_planFOREIGNKEY(plan_code,version_no)REFERENCESdemo_plan_version(plan_code,version_no))ENGINE=InnoDB;

为复现第一节的查询结果,可以在空的演示库中插入下面这组数据。PRO v4在 10 月 16 日作为新增版本发布,但甲公司的订阅仍指向 v3:

INSERTINTOdemo_tenantVALUES('t_a'),('t_b');INSERTINTOdemo_plan_versionVALUES('PRO',3,'CLOSED','2026-10-01 00:00:00'),('PRO',4,'ON_SALE','2026-10-16 00:00:00'),('BASIC',1,'ON_SALE','2026-10-01 00:00:00');INSERTINTOdemo_plan_featureVALUES('PRO',3,'batch_report',1),('PRO',4,'batch_report',1),('BASIC',1,'batch_report',0);INSERTINTOdemo_plan_quotaVALUES('PRO',3,'report_jobs',20,'COUNT','CALENDAR_MONTH'),('PRO',3,'storage_bytes',107374182400,'BYTE','CURRENT'),('PRO',4,'report_jobs',30,'COUNT','CALENDAR_MONTH'),('PRO',4,'storage_bytes',107374182400,'BYTE','CURRENT'),('BASIC',1,'storage_bytes',10737418240,'BYTE','CURRENT');INSERTINTOdemo_subscriptionVALUES('sub_101','t_a','PRO',3,'2026-09-30 16:00:00','2026-10-31 16:00:00','ACTIVE',1),('sub_201','t_b','BASIC',1,'2026-09-30 16:00:00','2026-10-31 16:00:00','ACTIVE',1);

订阅区间按北京时间 10 月 1 日至 11 月 1 日换算后以 UTC 入库;套餐的published_at也是 UTC,仅用于说明发布时间,不参与订阅有效性判断。示例将 v3 标为停止新售,正好验证旧订阅不因该状态而失效。实际发布流程应先发布 v3、创建订阅,后发布 v4 并关闭 v3 销售;演示插入一次给出最终状态,不代表生产可跳过流程校验。

unit_code与period_code要有约定:report_jobs使用COUNT / CALENDAR_MONTH,storage_bytes使用BYTE / CURRENT。不能把 100 GiB 写成数字100后让不同服务自行解释。生产环境最好由指标目录约束合法的键、单位和周期,避免同一quota_key在不同套餐里含义漂移。这里的 100 GiB 在存储时换算为107374182400字节。

uq_tenant_start只阻止相同起点的重复记录,不能阻止两个不同起点的时间区间重叠。创建或变更订阅时,应在事务里先锁定该租户行,再查重叠区间:new_start < old_end AND new_end > old_start。所有订阅写入都必须经过这一路径;如果允许绕过服务直接写库,还需额外的数据库侧保护或严格写权限。查询时仍要对“命中 0 条 / 多条”做显式处理,不能靠LIMIT 1掩盖脏数据。

图3:复合外键固定订阅指向的版本;区间重叠需要事务串行化校验。

四、如何解析某一时刻的有效权益

权益解析器输入应是服务端确认的tenantId与查询时刻,输出要同时包含结果和证据:订阅 ID、套餐版本、订阅修订号、能力集合、配额集合、查询时间。只返回hasFeature=true,排查时就无从解释它来自哪里。

recordSubscription(Stringid,StringtenantId,StringplanCode,intversion,InstantstartsAt,InstantendsAt,longrevision){}recordQuota(longlimit,Stringunit,Stringperiod){}recordEntitlement(StringsubscriptionId,StringplanCode,intversion,longrevision,InstantasOf,Set<String>features,Map<String,Quota>quotas){booleanhas(StringfeatureKey){returnfeatures.contains(featureKey);}}interfaceEntitlementRepository{List<Subscription>findActiveCovering(StringtenantId,Instantat);Set<String>enabledFeatures(StringplanCode,intversion);Map<String,Quota>quotas(StringplanCode,intversion);}finalclassEntitlementResolver{privatefinalEntitlementRepositoryrepo;EntitlementResolver(EntitlementRepositoryrepo){this.repo=repo;}Entitlementresolve(StringtenantId,Instantat){List<Subscription>matches=repo.findActiveCovering(tenantId,at);if(matches.size()!=1){thrownewIllegalStateException(matches.isEmpty()?"NO_ACTIVE_SUBSCRIPTION":"OVERLAPPING_SUBSCRIPTIONS");}Subscriptionsub=matches.get(0);returnnewEntitlement(sub.id(),sub.planCode(),sub.version(),sub.revision(),at,Set.copyOf(repo.enabledFeatures(sub.planCode(),sub.version())),Map.copyOf(repo.quotas(sub.planCode(),sub.version())));}}

findActiveCovering的筛选语义必须是sub_state='ACTIVE' AND starts_at <= :at AND ends_at > :at。不能再额外要求旧套餐sale_state='ON_SALE',否则“停止销售 v3”会无意中终止甲公司仍有效的订阅。服务还应确认租户当前状态:租户被冻结时,即使订阅记录仍在,业务执行也要被拒绝;这是租户状态校验,不应悄悄篡改权益事实。

以 10 月 16 日 10:30 查询为例,预期解析结果为:

tenant=t_a, subscription=sub_101, plan=PRO/v3, revision=1 features=[batch_report] quotas={report_jobs: 20 COUNT/CALENDAR_MONTH, storage_bytes: 107374182400 BYTE/CURRENT} tenant=t_b, subscription=sub_201, plan=BASIC/v1, revision=1 features=[] quotas={storage_bytes: 10737418240 BYTE/CURRENT}

这里的20是上限,不是“还剩 20”。如果已经创建 7 个报表任务,还剩多少要看用量事实、失败是否退还、时间窗口以及并发预留策略;这属于后续配额控制专题。把“套餐上限”直接当“剩余额度”显示,会在并发和失败补偿时误导用户。

缓存也要保留来源。可以按tenantId + subscription revision缓存权益快照,但订阅变更后必须提高修订号并让旧缓存失效;对立即撤销要求高的执行路径,不能只信长 TTL 缓存。具体执行鉴权还要叠加成员动作权限与资源归属,下一篇单独讨论。

图4:解析只读取被订阅绑定的版本;无订阅或多订阅命中都应显式失败。

五、用测试和 SQL 证明结果没有漂移

关键测试不是“能查到套餐”,而是“新版本发布后老订阅保持原结果”和“异常订阅不被静默选中”。以下 JUnit 5 示例使用内存仓储夹具repo,其方法与上文接口一致;测试数据按第一节建立:

@TestvoidpublishingV4DoesNotUpgradeExistingSubscription(){Instantat=Instant.parse("2026-10-16T02:30:00Z");Entitlementbefore=newEntitlementResolver(repo).resolve("t_a",at);repo.publishVersion("PRO",4,30);// 只新增模板版本,不改 sub_101Entitlementafter=newEntitlementResolver(repo).resolve("t_a",at);assertEquals("sub_101",after.subscriptionId());assertEquals(3,after.version());assertEquals(20,after.quotas().get("report_jobs").limit());assertEquals(before,after);}@TestvoidrightBoundaryAndOverlapMustNotBeGuessed(){EntitlementResolverresolver=newEntitlementResolver(repo);assertEquals("NO_ACTIVE_SUBSCRIPTION",assertThrows(IllegalStateException.class,()->resolver.resolve("t_a",Instant.parse("2026-10-31T16:00:00Z"))).getMessage());repo.forceInsertOverlappingSubscriptionForTest("t_a","sub_bad");assertEquals("OVERLAPPING_SUBSCRIPTIONS",assertThrows(IllegalStateException.class,()->resolver.resolve("t_a",Instant.parse("2026-10-16T02:30:00Z"))).getMessage());}

这段展示的是服务层契约测试,publishVersion和forceInsertOverlappingSubscriptionForTest是测试夹具方法,不是生产管理接口;后者特意绕过写入校验,证明读取端面对脏数据会拒绝。数据库集成测试还要验证外键、CHECK、并发订阅写入是否符合预期。CHECK的强制执行需要 MySQL 8.0.16 及以上。

上线核对时,先查同一时刻实际命中的订阅,再追到套餐明细;不要只看管理后台显示的“专业版”文字。

SET@at='2026-10-16 02:30:00.000000';-- UTC,即北京时间 10:30SELECTtenant_id,COUNT(*)ASactive_countFROMdemo_subscriptionWHEREsub_state='ACTIVE'ANDstarts_at<=@atANDends_at>@atANDtenant_idIN('t_a','t_b')GROUPBYtenant_id;-- 预期:t_a=1,t_b=1;不是 1 的租户需要人工核查。SELECTs.tenant_id,s.subscription_id,s.plan_code,s.version_no,f.feature_key,f.enabled,q.quota_key,q.limit_value,q.unit_code,q.period_codeFROMdemo_subscription sLEFTJOINdemo_plan_feature fONf.plan_code=s.plan_codeANDf.version_no=s.version_noLEFTJOINdemo_plan_quota qONq.plan_code=s.plan_codeANDq.version_no=s.version_noWHEREs.tenant_id='t_a'ANDs.sub_state='ACTIVE'ANDs.starts_at<=@atANDs.ends_at>@at;-- 此 JOIN 会把能力行与配额行交叉展开;核对 t_a 的版本应为 3,-- report_jobs 的 limit_value 应为 20,不可用结果行数当能力或配额数量。SELECTa.tenant_id,a.subscription_idASfirst_id,b.subscription_idASsecond_idFROMdemo_subscription aJOINdemo_subscription bONa.tenant_id=b.tenant_idANDa.subscription_id<b.subscription_idANDa.starts_at<b.ends_atANDa.ends_at>b.starts_atWHEREa.sub_state='ACTIVE'ANDb.sub_state='ACTIVE';-- 预期 0 行;出现结果表示有效区间重叠,不能让读取端随便选一个。

第二个查询为了人工追溯把两张明细一起关联,容易产生笛卡尔式的行数放大;正式统计应分别聚合能力和配额,或者先分别查询再在服务层装配。这个细节很重要:上线检查脚本自身也可能制造“重复权益”的假象。

图5:验收既核对正常租户的版本和上限,也核对无订阅、区间重叠等拒绝路径。

六、边界、上线验收与延伸阅读

上线前至少准备三组事实:发布前后的套餐版本快照、每个租户的订阅区间、同一时刻的权益解析结果。对甲公司,发布PRO v4后仍应解释为sub_101 → PRO v3 → 每月 20 次;对乙公司,即使成员被误授report:run动作,租户也没有batch_report能力。订阅到期、数据保留与升级生效时间要另订规则,不能由本文示例的“无有效订阅就拒绝”推断所有产品都必须立即删除数据。

本文还没有解决“20 次如何在并发下准确扣减”。权益解析只给出政策上限,运行时需要配额用量、预留、幂等和失败补偿。也没有把套餐当个人授权:能力开通只能说明这个租户可以使用某功能,真正执行仍需要当前成员的动作权限与目标资源归属。把这几层分开,平台才能回答“为什么这个租户现在能用、什么时候开始、依据的是哪个版本”,也才能在争议时拿出证据。

参考资料:

  • AWS SaaS Lens:Tenant Tiers,用于理解租户分层不只是价格标签,还会影响服务体验和运维边界。
  • MySQL 8.0:Foreign Key Constraints,用于核对版本引用的复合外键约束。
  • MySQL 8.0:TABLE_CONSTRAINTS,用于核对CHECK约束在 8.0.16 起的执行边界。
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 2:40:12

Git团队协作实战:从快照模型到分支管理与故障排查

我最早把Git版本控制系统当作正经工具来用&#xff0c;是在维护一个多端共用的业务项目时。当时仓库里已经堆了几十个分支&#xff0c;有人习惯直接在主干上改&#xff0c;有人开了分支三个月不合并&#xff0c;每次发版前最怕的不是业务复杂度&#xff0c;而是把线上分支合并回…

作者头像 李华
网站建设 2026/10/17 2:39:50

服务账号+域级授权:跨域用户管理API批量操作实战

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

作者头像 李华
网站建设 2026/10/17 2:38:59

OpenSSL 1.1.1源码编译与TLS 1.3配置实战指南

简介&#xff1a;OpenSSL 1.1.1 是面向 Windows 32 位系统的 SSL/TLS 协议实现库&#xff0c;适合程序员、运维人员及安全测试人员在 Windows 环境下进行加密通信开发、证书管理和安全连接调试&#xff0c;能够有效解决数据在传输过程中的保密性、完整性与身份认证等问题。压缩…

作者头像 李华
网站建设 2026/10/17 2:37:46

信息论|联合熵、条件熵、互信息、熵不等式、典型习题训练

** 信息论&#xff5c;联合熵、条件熵、互信息、熵不等式、典型习题训练 内容&#xff1a;信息论基础 &#xff0c;联合熵 &#xff0c;条件熵 &#xff0c;互信息 &#xff0c;信息论计算题 前言 上篇学习了单变量信源的自信息量与信息熵。在实际通信系统中&#xff0c;发送端…

作者头像 李华