租户停用后数据和任务怎么处理?冻结、恢复与保留策略的实现

平台把一个租户标成"停用",管理页面立即灰了,却仍有旧会话在调用接口、队列里还有任务、Worker 正在写文件,之前签发的下载链接也可能尚未过期。把 tenant.enabled 改成 false 只是状态变更的第一步,真正的问题是:从哪个时刻起,哪些副作用必须停止?恢复后又允许哪些旧工作继续?

本文用一个虚构租户的冻结与恢复过程,拆清账号、租户、任务、文件和数据保留的边界。示例采用 Java 17、Spring Boot 风格服务层和 MySQL 8.0.16 及以上版本;状态、时间和数据均为教学示意,不对应实际客户或内部产品策略。

目录

  1. 固定案例:冻结时有任务正在执行
  2. 停用不等于到期,更不等于删除
  3. 状态版本和事件记录怎样保存冻结证据
  4. [请求、Worker 和产物登记怎样守住边界](#请求、Worker 和产物登记怎样守住边界)
  5. 恢复为什么不能自动重放旧任务
  6. [测试、SQL 验证与上线验收](#测试、SQL 验证与上线验收)

一、固定案例:冻结时有任务正在执行

沿用甲公司 t_a 和乙公司 t_b。甲公司在 2026-10-18 11:59 处于 ACTIVE,状态版本 epoch=7;乙公司仍正常服务。账号 u_07 同时是两家公司的成员,平台人员 p_01 有租户状态操作权限,但没有默认读取甲公司业务数据的权限。

甲公司此时有三类对象:待执行报表任务 j_wait 在版本 7 提交;运行中任务 j_run 在版本 7 领取,正在写私有临时文件;昨天完成的文件 f_old 已登记为历史产物。平台在北京时间 12:00 冻结甲公司,并在 16:00 核验后恢复。

时点 甲公司状态 新业务请求 j_wait j_run f_old
11:59 ACTIVE / 7 按成员与套餐权限处理 可以等待领取 已启动 按原权限下载
12:00 冻结提交后 SUSPENDED / 8 拒绝甲公司业务访问 不得领取;随后标记阻塞 发取消信号;不得登记新产物 暂停签发下载地址
16:00 恢复提交后 ACTIVE / 9 重新核对成员和权益后允许 不自动重跑 不自动续跑 若仍在保留期且有读取权,可重新下载

冻结与恢复都使版本递增。这样一个版本 7 的 Worker 不能在版本 9 时说"租户又 ACTIVE 了,我可以继续提交结果"。乙公司 t_b 的状态和任务不应受甲公司的冻结影响。账号 u_07 若在乙公司仍有有效成员身份,也不该因为甲公司被停用而全局失去登录能力。

图1:冻结改变的是甲公司的执行边界,不是账号的全局身份,也不是数据的物理存在。

二、停用不等于到期,更不等于删除

上一篇讨论套餐降级时,本例允许已启动任务完成;租户冻结不能照搬这条宽限政策。套餐变化是产品权益变化,租户停用可能出于安全、运营或合同原因。系统至少要把下面几件事分开判断:

对象 冻结后的处理 恢复后的处理
登录与会话 账号可继续认证,但选择 t_a 进入业务面或使用旧会话调用 t_a 接口应被拒绝 重新解析 t_a 成员身份,不能复用冻结前的授权快照
新请求与写操作 拒绝新建、修改、导出与下载等租户业务操作 再核对成员权限、套餐有效性和目标归属
排队任务 立即变成不可领取;异步清理为 BLOCKED 保留失败历史,按新请求显式重提,不自动重放
运行中任务 请求取消;完成登记有硬性版本检查 旧版本任务不得因恢复而继续发布结果
已完成文件 私有文件保留,但不再签发新的业务下载地址 在原保留期限内,经身份与读取权限校验后可读
业务数据与审计 不因冻结立即物理删除 保持原归属与历史,按独立保留策略处理

这里的"拒绝下载"有一个工程边界:之前已经签发的对象存储预签名 URL 可能在其有效期内继续工作。只在数据库里改租户状态,不能保证已流出的 URL 瞬间失效。对需要强即时封禁的对象,必须采用服务端代理下载、足够短的 URL 有效期、存储访问策略或凭据撤销等控制,并验证不同客户端的缓存行为;不要在文档里承诺系统做不到的"冻结即撤回所有已签发链接"。

冻结也不代表"马上删除文件"或"保留期限自动重置"。f_old 的保留时间照原数据政策推进;若存在另行批准的保全要求,应由独立的保留或冻结策略记录。数据归属、可见性、物理保留和最终销毁是四件事,不能让一个 SUSPENDED 状态同时承担它们。

图2:停用先切断执行与交付,再按任务状态清理;数据保留遵守独立政策。

三、状态版本和事件记录怎样保存冻结证据

前文已有 demo_tenant 与 demo_report_job。这里增加租户生命周期状态、单调递增的版本号,以及可重放的状态事件。演示中的 epoch 不是时间戳,也不是套餐修订号;它只说明租户经历了几次生命周期转换。

sql 复制代码
ALTER TABLE demo_tenant
  ADD COLUMN lifecycle_state VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
  ADD COLUMN lifecycle_epoch BIGINT NOT NULL DEFAULT 0,
  ADD COLUMN lifecycle_changed_at DATETIME(6) NULL,
  ADD CONSTRAINT ck_tenant_lifecycle
    CHECK (lifecycle_state IN ('ACTIVE', 'SUSPENDED'));

CREATE TABLE demo_tenant_state_event (
  event_id VARCHAR(36) PRIMARY KEY,
  tenant_id VARCHAR(32) NOT NULL,
  command_key VARCHAR(64) NOT NULL,
  from_state VARCHAR(16) NOT NULL,
  to_state VARCHAR(16) NOT NULL,
  from_epoch BIGINT NOT NULL,
  to_epoch BIGINT NOT NULL,
  reason_code VARCHAR(40) NOT NULL,
  operator_id VARCHAR(32) NOT NULL,
  changed_at DATETIME(6) NOT NULL,
  processed_at DATETIME(6) NULL,
  CONSTRAINT uq_tenant_state_command UNIQUE (tenant_id, command_key),
  INDEX ix_tenant_event_pending (processed_at, changed_at),
  CONSTRAINT fk_state_tenant FOREIGN KEY (tenant_id)
    REFERENCES demo_tenant (tenant_id),
  CONSTRAINT ck_state_epoch CHECK (to_epoch = from_epoch + 1)
) ENGINE=InnoDB;

ALTER TABLE demo_report_job
  ADD COLUMN created_epoch BIGINT NULL,
  ADD COLUMN claimed_epoch BIGINT NULL,
  ADD COLUMN cancel_requested BOOLEAN NOT NULL DEFAULT 0,
  ADD COLUMN completed_at DATETIME(6) NULL,
  ADD COLUMN completed_epoch BIGINT NULL;

created_epoch 在新任务提交时写当前租户版本,claimed_epoch 在 Worker 成功领取时写当前版本。演示表允许 created_epoch 暂为空,是为了兼容既有行;迁移时必须核查和回填历史任务,然后把新任务 的非空约束收紧。不能让 NULL 被解释为"忽略版本"。冻结时不必在同一事务里扫描全租户所有任务:先提交状态与事件,所有新请求、任务领取与结果登记立即按新状态拒绝;随后事件消费者按 tenant_id 和旧版本批量标记任务、发取消信号。即使清理队列稍慢,旧版本任务也不能跨过领取与完成检查。

事件表同时是恢复清理的驱动记录。processed_at 只表示异步清理已经完成,不能当成冻结生效时刻;真正的边界是 demo_tenant 状态事务提交时。重复收到同一个 command_key 应返回既有事件,不再把版本从 8 加到 9。恢复必须使用新的请求号和授权操作,不能把冻结命令重放为恢复。

冻结事件即使在恢复后才被消费,也只应处理 created_epoch < 8 或 claimed_epoch < 8 的旧任务,不得按"甲公司全部任务"扫描并阻塞版本 9 的新任务。事件消费也要幂等:重复投递不能重复释放预留名额或覆盖已经完成的历史任务。

图3:状态版本阻止旧任务穿越冻结与恢复;事件留下谁在何时改变状态的证据。

四、请求、Worker 和产物登记怎样守住边界

冻结操作必须由平台授权身份发起,记录原因,并与其他租户状态写入串行。下面用服务层代码表示关键事务;lockTenant 执行 SELECT ... FOR UPDATE,eventByKey 用于命令幂等。旧状态不符合预期时返回冲突,而不是盲目覆盖。

java 复制代码
enum TenantState { ACTIVE, SUSPENDED }
record TenantRow(String id, TenantState state, long epoch) {}
record StateEvent(String id, String tenantId, TenantState from,
                  TenantState to, long fromEpoch, long toEpoch) {}

interface LifecycleStore {
    TenantRow lockTenant(String tenantId);
    Optional<StateEvent> eventByKey(String tenantId, String commandKey);
    void updateState(String tenantId, TenantState expected,
                     TenantState target, long expectedEpoch, long nextEpoch);
    void appendEvent(String tenantId, String commandKey, TenantState from,
                     TenantState to, long fromEpoch, long toEpoch,
                     String reasonCode, String operatorId);
}

final class TenantLifecycleService {
    private final LifecycleStore store;
    private final PlatformAuthorization platformAuth;

    @Transactional
    long change(String operatorId, String tenantId, TenantState expected,
                TenantState target, String reasonCode, String commandKey) {
        platformAuth.requireManageTenant(operatorId);
        TenantRow row = store.lockTenant(tenantId);
        var prior = store.eventByKey(tenantId, commandKey);
        if (prior.isPresent()) return prior.get().toEpoch();
        if (row.state() != expected || expected == target)
            throw new IllegalStateException("TENANT_STATE_CONFLICT");
        long next = row.epoch() + 1;
        store.updateState(tenantId, expected, target, row.epoch(), next);
        store.appendEvent(tenantId, commandKey, expected, target,
                          row.epoch(), next, reasonCode, operatorId);
        return next;
    }
}

请求侧的规则应在服务层写入前再核对一次,而不只依赖登录时的租户状态。凡会产生新任务、变更数据、签发下载地址的接口,先从可信上下文确定当前租户;高风险写入使用与生命周期变更一致的租户行锁或等价的原子版本条件,保证"冻结事务已提交之后不再产生新副作用"。只在入口网关缓存 ACTIVE 数分钟,无法满足这个边界。

Worker 需要两道门。领取门 要求租户当前为 ACTIVE,任务 created_epoch 等于当前 epoch,并重新核对成员、套餐、配额等事实。完成门 要求租户仍为 ACTIVE、任务 claimed_epoch 等于当前 epoch、未收到取消请求、任务仍为 RUNNING。完成登记也锁租户行,让它与冻结事务形成确定先后:若完成先提交,文件属于冻结前的已完成结果,冻结后下载仍拒绝;若冻结先提交,旧 Worker 无法登记新产物。

java 复制代码
@Transactional
void publishResult(String jobId, String privateTempKey) {
    ReportJob read = jobs.find(jobId); // 仅用来取得可信 tenantId
    TenantRow tenant = tenants.lockTenant(read.tenantId());
    ReportJob job = jobs.lockJob(jobId);
    if (!job.tenantId().equals(tenant.id())
        || tenant.state() != TenantState.ACTIVE
        || job.claimedEpoch() == null
        || job.claimedEpoch() != tenant.epoch()
        || job.cancelRequested()
        || !job.state().equals("RUNNING")) {
        jobs.rejectStaleCompletionIfRunning(jobId, "TENANT_EPOCH_CHANGED");
        cleanup.recordIntentIfUnreferenced(privateTempKey);
        return;
    }
    jobs.attachPrivateOutputAndComplete(jobId, privateTempKey,
                                        tenant.epoch());
}

对象存储与数据库不是同一个事务。这里的 privateTempKey 必须始终位于不可直接公开的私有空间;只有数据库完成登记后,业务下载服务才可以按任务归属签发访问。attachPrivateOutputAndComplete 在同一事务中记录完成时间与 completed_epoch。recordIntentIfUnreferenced 只在数据库写清理意图,且必须确认文件键未被任何正式产物引用;事务提交后的清理器还要复核引用关系再删除。不能在事务内调用外部删除接口,否则回滚后仍可能丢失文件。冻结先提交时,迟到文件仍可能物理存在,但不会变成可下载产物。rejectStaleCompletionIfRunning 也必须带状态条件,不能把已经完成的任务改回失败。若异步消息重复投递,DONE 与旧版本完成请求不得再次创建产物。生产环境还需要失败重试、租约和清理任务,这段代码只展示版本围栏的核心判断。

图4:Worker 可以晚收到取消通知,但旧版本完成请求不能越过结果登记门。

五、恢复为什么不能自动重放旧任务

16:00 恢复甲公司时,状态从 SUSPENDED / 8 变为 ACTIVE / 9。这一步只恢复租户可以重新接受合规请求 的资格,不是把冻结期间积压的工作全部重放。j_wait.created_epoch=7,即使异步清理尚未把它从 PENDING 标成 BLOCKED,领取门也必须拒绝版本 7;j_run.claimed_epoch=7,完成门同样拒绝。新任务要在版本 9 重新提交,并重新核对当前成员、套餐与配额。

恢复前建议核查四件事:停用原因是否解除、租户数据和存储空间是否仍可用、成员权限是否仍有效、订阅是否已到期。恢复租户状态不自动续订套餐;如果原订阅已经到期,租户可以进入系统的有限管理面,但受套餐控制的业务能力仍不可用。平台人员能操作状态,也不因此获得租户业务数据的默认读权限。

历史文件 f_old 在冻结前已完成,恢复后可以在原 download_until 与保留政策允许的范围内,由有 report:read 的当前成员读取。新签发的短时下载地址经过当前租户状态和任务归属检查。已经在冻结前流出的地址是否可被立即撤销,要按实际对象存储策略单独验收。不要用"恢复"来延长文件保留期,也不要把"不能下载"误写成"已删除"。

配额账本也不应因冻结归零。未启动任务若在冻结清理时被明确阻塞,应按上一文的政策释放其 RESERVED 名额;已经启动的任务即使因冻结被取消,本例仍保留其 COMMITTED 消耗。释放任务与预留要同事务、按任务唯一执行,避免冻结事件重复投递造成双重释放。

六、测试、SQL 验证与上线验收

第一组测试核对冻结后的旧会话、旧任务与文件访问;第二组测试模拟冻结和完成登记竞争同一租户锁,确保结果由提交顺序决定。下列 JUnit 5 示例使用测试夹具 Fixture,其数据库和私有存储替身按第一节固定输入准备:

java 复制代码
@Test
void freezeRejectsOldSessionAndOldWorkerButLeavesOtherTenantWorking() {
    Fixture f = Fixture.twoTenantsAtEpoch(7);
    f.lifecycle.change("p_01", "t_a", TenantState.ACTIVE,
        TenantState.SUSPENDED, "SECURITY_REVIEW", "freeze-101");

    assertEquals("TENANT_SUSPENDED",
        f.api.submitCode("u_07", "t_a", "a_1001"));
    assertFalse(f.worker.canClaim("j_wait"));
    assertFalse(f.worker.canPublish("j_run"));
    assertFalse(f.download.canIssue("u_07", "t_a", "f_old"));
    assertTrue(f.api.canRead("u_07", "t_b", "b_1001"));
    assertEquals(0, f.outputs.newVisibleFor("j_run"));
}

@Test
void restoreDoesNotReplayJobsFromBeforeFreeze() {
    Fixture f = Fixture.twoTenantsAtEpoch(7);
    f.freezeAndRestore("t_a"); // 版本依次为 8、9

    assertEquals(9, f.tenants.epoch("t_a"));
    assertFalse(f.worker.canClaim("j_wait"));
    assertFalse(f.worker.canPublish("j_run"));
    assertTrue(f.download.canIssue("u_07", "t_a", "f_old"));
    assertEquals("ACTIVE", f.tenants.state("t_a"));
}

@Test
void repeatingFreezeCommandDoesNotIncrementEpochTwice() {
    Fixture f = Fixture.twoTenantsAtEpoch(7);
    f.freeze("t_a", "freeze-101");
    f.freeze("t_a", "freeze-101");
    assertEquals(8, f.tenants.epoch("t_a"));
    assertEquals(1, f.events.countByCommand("t_a", "freeze-101"));
}

这些夹具方法表达应验证的契约,不是完整生产实现。最关键的并发集成测试要控制两个事务的先后:冻结先提交 时,旧 Worker 的 publishResult 不能登记文件;完成先提交 时,DONE 是冻结前的事实,但冻结后新下载仍被拒绝。还要测试 Worker 收不到取消通知、事件重复投递、旧预签名链接、冻结时权益查询故障、恢复时订阅已到期,以及乙公司继续正常工作。

上线后用下面的查询核对状态、事件和旧任务。冻结后的即时验收不应要求异步清理已全部完成,但必须要求没有新领取和新产物登记;等事件消费者处理后,再检查旧任务是否进入阻塞或取消结果。

sql 复制代码
SELECT tenant_id, lifecycle_state, lifecycle_epoch, lifecycle_changed_at
FROM demo_tenant WHERE tenant_id IN ('t_a', 't_b');
-- 冻结后:t_a=SUSPENDED/8,t_b 状态与版本不变;
-- 恢复后:t_a=ACTIVE/9。

SELECT tenant_id, from_state, to_state, from_epoch, to_epoch,
       command_key, reason_code, processed_at
FROM demo_tenant_state_event
WHERE tenant_id = 't_a' ORDER BY changed_at;
-- 预期两条不同命令:ACTIVE/7→SUSPENDED/8,
-- SUSPENDED/8→ACTIVE/9;重复 freeze-101 不应多一条。

SELECT job_id, state, created_epoch, claimed_epoch,
       cancel_requested, output_key
FROM demo_report_job
WHERE tenant_id = 't_a' AND job_id IN ('j_wait', 'j_run');
-- 冻结清理完成后:旧待执行任务不得为可领取的 PENDING;
-- 旧运行任务不得因恢复而登记新的 output_key。

SELECT job_id, claimed_epoch, completed_epoch, completed_at
FROM demo_report_job
WHERE tenant_id = 't_a' AND state = 'DONE'
  AND claimed_epoch = 7
  AND completed_at >= '2026-10-18 04:00:00';
-- 演示数据以 UTC 存储时间,04:00 对应北京时间 12:00。
-- 预期 0 行:版本 7 的任务在冻结后、包括恢复后都不能新完成。

SELECT job_id, claimed_epoch, completed_epoch
FROM demo_report_job
WHERE tenant_id = 't_a' AND state = 'DONE'
  AND completed_epoch <> claimed_epoch;
-- 预期 0 行;任何跨版本完成都要调查。

查询时间必须与数据库的 UTC 存储约定一致,不能把北京时间直接填入;completed_at 应由完成事务写入。即便两条查询都是 0 行,也只能证明数据库登记结果符合预期,不能证明私有存储里没有孤儿文件,还应对照清理意图和对象存储日志。迁移前的历史记录若 completed_epoch 为空,应单独核实,不能被这些条件悄悄排除。

图5:验收需要同时看接口拒绝、任务围栏、产物登记和另一租户是否照常服务。

本文的核心不是设计一个更复杂的"停用按钮",而是确定冻结事务提交后的执行边界。状态版本让旧任务不能借恢复重新获得执行权;事件与任务状态留下可解释的处理历史;私有文件和独立保留规则避免把停用误当删除。系统还需要诚实面对旧下载链接与外部存储的即时撤销能力,才能在运营和安全要求之间给出可信承诺。

参考资料:

相关推荐
CarIise1 小时前
Spring MVC 请求参数绑定与常用注解
java·spring·mvc
vx_Biye_Design1 小时前
PHP中医养生馆预约系统60841-计算机课程设计、毕业设计
vue.js·spring boot·后端·mysql·php·课程设计·express
一条泥憨鱼1 小时前
AI Agent 安全实践:从 MCP 工具调用到 Java 权限校验
java·人工智能·安全
Nebula_g1 小时前
云禾外卖补充—项目整体后端运行逻辑-对流程的更深层次理解
java·开发语言·网络·数据库·状态模式·javase
萧瑟余晖1 小时前
Spring Security 6.x新特性与架构演进详解
java·spring·架构
\光辉岁月/1 小时前
1.Object类
java·面向对象·object类
程序员小孔.1 小时前
基于 SpringBoot + UniApp + Vue 的家政服务系统设计与实现
前端·数据库·spring boot·python·docker·微信小程序
鸽鸽程序猿2 小时前
【项目】【在线判题系统】公共功能
java·spring boot·maven
一只笨菜鸟2 小时前
IDEA如何让多个项目同时显示
java·ide·intellij-idea