5.2 数据流与状态归属

功能树钉死了「功能归谁」,这一节钉死「状态归谁」:每份状态有且只有一个写者,可被多个模块读。用《王朝纪元》9 份状态的归属表校验单写者、归属一致与引用有效,再用耦合度基准把「活动模块读了四个模块的状态」这类越界软告警出来,让模块边界真正稳固。

上一节把「功能归谁」钉死了,但一个功能是不是稳定,取决于它背后那份状态是不是稳定。SLG 里最典型的翻车方式,不是某个功能没人做,而是同一份数据被两个模块同时改:城建在扣木材,联盟互助也在扣木材,两边各算各的,最后资源凭空多出来或者凭空消失。

先划边界。站内的 SLG 技术架构专题 讲的是技术架构:前后端分层、服务端模块拆分、通信协议,明确写了「不讨论玩法数值」。本节讲的是它没碰的另一半——产品侧的状态归属,也就是「这份数据由哪个模块负责写、哪些模块只能读」。至于账本层面的守恒校验(每一笔增减都要对得上),那是第 9 章的 账本模型与守恒 的事;本节只解决「谁有权写」。

状态要认主:单写者原则

给系统上锁之前,先定一条规矩:每份状态有且只有一个写者(single writer)。

  • 「写」= 改变这份状态的值;
  • 「读」= 只获取这份状态的值,不改变它;
  • 一份状态可以被多个模块读,但只能被一个模块写。

这条规矩的价值在于把「改数据」这件事收敛到一个点上。只要所有人都知道「资源存量只有城建能写」,那么:

  • 排查 bug 时,你只需要看一个模块的写入逻辑;
  • 改数值时,你只需要改一处;
  • 做存档时,你只需要保证一个模块的写入是原子的。

反过来,一旦一份状态有两个写者,你就失去了这三个性质——而这正是大型 SLG 项目里最常见、也最难查的一类问题。

写者、读者与流

把每份状态写成一行,至少要有四个字段:owner(归属模块)、writers(写者)、readers(读者)、id(状态标识)。再补一组 flows,描述「谁把哪份状态的变化推给谁」——注意这是事件推送,不是数据所有权转移。

这里有个容易混的点:读者多不等于耦合重。一份状态被十个模块读,只要写者唯一,它就是一份稳定的「只读资源」;一份状态被两个模块写,哪怕只有两个,它也是一个随时会炸的定时炸弹。判断耦合要看写,不是看读。这一点会在下面的基准里体现。

把状态归属写成配置

下面这段是 D 层配置:9 份状态的归属表,外加 6 条跨模块事件流。注意每份状态的 writers 长度恒为 1,且等于 owner。

{
  "version": 1,
  "note": "状态归属表:每份状态(state)有且只有一个写者(writers 长度恒为 1 且等于 owner),可被多个模块读(readers)。flows 描述模块间的事件推送(谁把哪份状态的变化推给谁)。",
  "modules": ["city", "army", "map", "alliance", "quest", "shop", "event", "account"],
  "states": [
    { "id": "city.resources", "name": "资源存量", "owner": "city", "writers": ["city"], "readers": ["city", "army", "shop", "event"] },
    { "id": "city.buildings", "name": "建筑等级", "owner": "city", "writers": ["city"], "readers": ["city", "army", "quest"] },
    { "id": "army.troops", "name": "部队编成", "owner": "army", "writers": ["army"], "readers": ["army", "map"] },
    { "id": "map.tiles", "name": "地块归属", "owner": "map", "writers": ["map"], "readers": ["map", "army", "alliance"] },
    { "id": "alliance.members", "name": "联盟成员", "owner": "alliance", "writers": ["alliance"], "readers": ["alliance", "event"] },
    { "id": "quest.progress", "name": "任务进度", "owner": "quest", "writers": ["quest"], "readers": ["quest", "event"] },
    { "id": "shop.wallet", "name": "付费钱包", "owner": "shop", "writers": ["shop"], "readers": ["shop", "event"] },
    { "id": "event.season", "name": "赛季进度", "owner": "event", "writers": ["event"], "readers": ["event", "quest"] },
    { "id": "account.profile", "name": "账号档案", "owner": "account", "writers": ["account"], "readers": ["account", "city", "alliance", "shop"] }
  ],
  "flows": [
    { "id": "city.produced", "from": "city", "to": "army", "payload": "city.resources", "kind": "event" },
    { "id": "army.marched", "from": "army", "to": "map", "payload": "army.troops", "kind": "event" },
    { "id": "map.tileChanged", "from": "map", "to": "alliance", "payload": "map.tiles", "kind": "event" },
    { "id": "alliance.memberChanged", "from": "alliance", "to": "event", "payload": "alliance.members", "kind": "event" },
    { "id": "quest.completed", "from": "quest", "to": "event", "payload": "quest.progress", "kind": "event" },
    { "id": "shop.purchased", "from": "shop", "to": "event", "payload": "shop.wallet", "kind": "event" }
  ]
}

readers 里包含 owner 自己——一个模块当然可以读自己写的状态。真正的边界信息在「除了自己还有谁」。

校验它

下面这段是 S 层仿真脚本,把归属表逐条校验:单写者、归属一致、引用有效、无死状态、模块覆盖,产出 JSON、CSV 与一张模块-状态二部图(写实线、读虚线)。

# file: sim/ch05_state_ownership.py
# tier: S
# verify: python3 sim/ch05_state_ownership.py --config config/
"""状态归属校验(S 层):单写者、归属一致、引用有效、无死状态、模块覆盖。
产出 JSON(stdout + out/)+ CSV(各模块写/读状态数)+ SVG(模块-状态二部图)。
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET

NS = "http://www.w3.org/2000/svg"


def load(cfg_dir: str, name: str) -> dict:
    with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as f:
        return json.load(f)


def validate(cfg: dict) -> dict:
    modules, states, flows = list(cfg["modules"]), cfg["states"], cfg["flows"]
    modset, V = set(modules), []
    for s in states:
        sid, writers, readers = s["id"], s.get("writers", []), s.get("readers", [])
        if len(writers) != 1:  # 单写者:同一份状态只能有一个写模块
            V.append({"code": "MULTI_WRITER", "target": sid, "detail": f"写者 {len(writers)} 个:{writers}"})
        if s.get("owner") not in modset:
            V.append({"code": "BAD_OWNER", "target": sid, "detail": f"归属模块不存在:{s.get('owner')}"})
        elif writers and s["owner"] not in writers:
            V.append({"code": "OWNER_NOT_WRITER", "target": sid, "detail": f"归属 {s['owner']} 不在写者 {writers}"})
        for m in writers + readers:
            if m not in modset:
                V.append({"code": "BAD_MODULE_REF", "target": sid, "detail": f"引用未知模块:{m}"})
        if not readers:
            V.append({"code": "DEAD_STATE", "target": sid, "detail": "无任何模块读取(死状态)"})
    ids = {s["id"] for s in states}
    for fl in flows:
        if fl["from"] not in modset or fl["to"] not in modset:
            V.append({"code": "BAD_FLOW_MODULE", "target": fl["id"], "detail": f"端点模块不存在:{fl['from']}→{fl['to']}"})
        if fl["payload"] not in ids:
            V.append({"code": "BAD_FLOW_PAYLOAD", "target": fl["id"], "detail": f"载荷状态不存在:{fl['payload']}"})
    involved = set()
    for s in states:
        involved.update(s.get("writers", []))
        involved.update(s.get("readers", []))
    for m in modules:
        if m not in involved:
            V.append({"code": "IDLE_MODULE", "target": m, "detail": "未参与任何状态"})
    per = [{"module": m, "writes": sum(1 for s in states if m in s.get("writers", [])),
            "reads": sum(1 for s in states if m in s.get("readers", [])),
            "writeStates": [s["id"] for s in states if m in s.get("writers", [])],
            "readStates": [s["id"] for s in states if m in s.get("readers", [])]} for m in modules]
    return {"version": 1, "ok": not V,
            "counts": {"modules": len(modules), "states": len(states), "flows": len(flows),
                       "writeEdges": sum(len(s.get("writers", [])) for s in states),
                       "readEdges": sum(len(s.get("readers", [])) for s in states), "violations": len(V)},
            "violations": V, "perModule": per}


def write_svg(cfg: dict, report: dict, path: str) -> None:
    modules, states = list(cfg["modules"]), cfg["states"]
    rh, pad, top, mx, sx = 32, 30, 56, 180, 660
    H = top + max(len(modules), len(states)) * rh + pad
    W = sx + 250 + pad
    ET.register_namespace("", NS)
    svg = ET.Element(f"{{{NS}}}svg", {"width": str(W), "height": str(H), "viewBox": f"0 0 {W} {H}"})
    ET.SubElement(svg, f"{{{NS}}}rect", {"width": str(W), "height": str(H), "fill": "#0f1720"})
    ET.SubElement(svg, f"{{{NS}}}text", {"x": str(pad), "y": "28", "fill": "#e2e8f0", "font-size": "16",
                                         "font-family": "sans-serif"}).text = \
        f"《王朝纪元》状态归属:{len(modules)} 模块 / {len(states)} 状态 / 写 {report['counts']['writeEdges']}" \
        f" · 读 {report['counts']['readEdges']} / 违规 {report['counts']['violations']}"
    my = {m: top + i * rh for i, m in enumerate(modules)}
    sy = {s["id"]: top + i * rh for i, s in enumerate(states)}
    for m in modules:
        y = my[m]
        ET.SubElement(svg, f"{{{NS}}}rect", {"x": str(mx), "y": str(y - 11), "width": "110", "height": "22",
                                             "rx": "6", "fill": "#1d4ed8"})
        ET.SubElement(svg, f"{{{NS}}}text", {"x": str(mx + 55), "y": str(y + 4), "fill": "#fff", "font-size": "12",
                                             "text-anchor": "middle", "font-family": "sans-serif"}).text = m
    for s in states:
        y = sy[s["id"]]
        ET.SubElement(svg, f"{{{NS}}}rect", {"x": str(sx), "y": str(y - 11), "width": "230", "height": "22",
                                             "rx": "6", "fill": "#334155"})
        ET.SubElement(svg, f"{{{NS}}}text", {"x": str(sx + 115), "y": str(y + 4), "fill": "#e2e8f0",
                                             "font-size": "12", "text-anchor": "middle",
                                             "font-family": "sans-serif"}).text = f"{s['name']} · {s['id']}"
        for w in s.get("writers", []):
            ET.SubElement(svg, f"{{{NS}}}line", {"x1": str(mx + 110), "y1": str(my[w]), "x2": str(sx),
                                                 "y2": str(y), "stroke": "#f59e0b", "stroke-width": "2"})
        for rd in s.get("readers", []):
            if rd in s.get("writers", []):
                continue
            ET.SubElement(svg, f"{{{NS}}}line", {"x1": str(mx + 110), "y1": str(my[rd]), "x2": str(sx),
                                                 "y2": str(y), "stroke": "#0ea5e9", "stroke-width": "1",
                                                 "stroke-dasharray": "4 3"})
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "out"))
    args = ap.parse_args()
    cfg = load(args.config, "ch05_state_ownership")
    report = validate(cfg)
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "ch05_state_ownership.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "ch05_state_ownership.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.DictWriter(f, fieldnames=["module", "writes", "reads", "writeStates", "readStates"])
        w.writeheader()
        for pm in report["perModule"]:
            w.writerow({**pm, "writeStates": "|".join(pm["writeStates"]), "readStates": "|".join(pm["readStates"])})
    write_svg(cfg, report, os.path.join(args.out, "ch05_state_ownership.svg"))
    print(json.dumps(report, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

跑一遍:9 状态 24 读边 0 违规

跑 python3 sim/ch05_state_ownership.py --config config/,报告头是:

{"version": 1, "ok": true,
 "counts": {"modules": 8, "states": 9, "flows": 6, "writeEdges": 9, "readEdges": 24, "violations": 0}}

writeEdges 是 9,正好等于状态数——每份状态一个写者,没有例外;readEdges 是 24,平均每份状态被 2.7 个模块读。各模块的读写分布是:

模块写状态数读状态数关键读入
city23账号档案
army14资源存量、建筑等级、地块归属
map12部队编成
alliance13地块归属、账号档案
quest13建筑等级、赛季进度
shop13资源存量、账号档案
event15资源存量、联盟成员、任务进度、付费钱包
account11账号档案

这张表立刻暴露了一个结构信号:活动模块读了 5 份状态,是全场最多的。这不是 bug,但值得警惕——一个模块读的状态越多,它就越容易在别人改状态时被动跟着改。下一节我们用基准把这个信号量化出来。

双写为什么是灾难

把「同一份状态被两个模块写」的情形单独拎出来看。假设有人给 city.resources 加了第二个写者 alliance(理由通常很正当:「联盟互助要扣资源」),会发生什么:

  1. 城建按自己的结算 tick 每小时加资源;
  2. 联盟在互助时直接减资源;
  3. 两个模块各自持有「资源存量」的一份缓存;
  4. 结算时两边用各自的缓存写回,后写的覆盖先写的——先写的那部分产出就凭空蒸发了。

更隐蔽的是,这种 bug 在单机测试里几乎测不出来:只要操作顺序固定,两边不会同时写。它只在并发或者时序交错时暴露,而 SLG 的服务器恰恰是并发环境。

所以「单写者」不是一句架构洁癖,它是一道并发安全的底线。校验器里的 MULTI_WRITER 就是这道底线的守卫:任何一份状态的 writers 长度一旦不等于 1,CI 直接红。真要做「联盟互助扣资源」,正确做法是联盟发一条命令给城建模块,由城建这个唯一写者去扣——这就是下一节 跨模块契约 要讲的东西。

耦合度基准

有了归属表,就能算每个模块的耦合度:fan-out 是它读到的外部状态所属的不同模块数,fan-in 是读到它拥有的状态的不同模块数。下面这段是 B 层基准脚本,把每个模块的 fan-out / fan-in 与一个期望上界对照,超出只软告警、不硬失败。

# file: sim/ch05_contract_bench.py
# tier: B
# verify: python3 sim/ch05_contract_bench.py --config config/
"""状态耦合度基准(B 层):从状态归属表算出每个模块的 fan-out / fan-in,与基准上界对照。
软告警不硬失败,退出码恒为 0;产出 JSON(stdout + out/ch05_contract_bench.json)。
"""

from __future__ import annotations

import argparse
import json
import os


def load(cfg_dir: str, name: str) -> dict:
    with open(os.path.join(cfg_dir, f"{name}.json"), encoding="utf-8") as f:
        return json.load(f)


def coupling(ownership: dict) -> list[dict]:
    states = ownership["states"]
    owner = {s["id"]: s["owner"] for s in states}
    rows = []
    for m in ownership["modules"]:
        reads = [s for s in states if m in s.get("readers", [])]
        fan_out = {owner[s["id"]] for s in reads if owner[s["id"]] != m}
        owned = [s["id"] for s in states if s["owner"] == m]
        fan_in = {r for s in states if s["owner"] == m for r in s.get("readers", []) if r != m}
        rows.append({"module": m, "fanOut": len(fan_out), "fanIn": len(fan_in)})
    return rows


def bench(cfg_dir: str) -> dict:
    ownership = load(cfg_dir, "ch05_state_ownership")
    base = load(cfg_dir, "ch05_bench_baseline")
    rows, warns = [], []
    for c in coupling(ownership):
        b = base["modules"].get(c["module"], base["defaults"])
        for axis, lim in (("fanOut", b["maxFanOut"]), ("fanIn", b["maxFanIn"])):
            if c[axis] > lim:
                warns.append({"module": c["module"], "axis": axis, "actual": c[axis], "limit": lim,
                              "detail": f"{c['module']} 的 {axis}={c[axis]} 超出基准上界 {lim}"})
        rows.append({**c, "maxFanOut": b["maxFanOut"], "maxFanIn": b["maxFanIn"],
                     "status": "warn" if c["fanOut"] > b["maxFanOut"] or c["fanIn"] > b["maxFanIn"] else "ok"})
    return {"version": 1, "tier": "B", "note": "软告警基准:只报告不硬失败,绝对数值不可移植。",
            "counts": {"modules": len(rows), "warnings": len(warns)}, "modules": rows, "warnings": warns}


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--config", default="config/")
    ap.add_argument("--out", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "out"))
    args = ap.parse_args()
    report = bench(args.config)
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "ch05_contract_bench.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(json.dumps(report, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

跑 python3 sim/ch05_contract_bench.py --config config/,得到 {"counts": {"modules": 8, "warnings": 1}},唯一的告警是:

模块fan-out上界fan-in上界状态
city1145ok
army2312ok
map1223ok
alliance2212ok
quest2211ok
shop2212ok
event4213warn
account0034ok

event 的 fan-out 是 4(它读了城建、联盟、任务、商城四个模块的状态),超出基准上界 2。这不是说活动模块做错了,而是说它承担了过多的外部依赖:任何一个上游模块改了状态结构,活动都要跟着改。合理的收敛方向是——活动模块不要直接读四家的状态,而是订阅它们各自发出的领域事件(city.produced、alliance.memberChanged……也就是归属表里的 flows),把「主动拉」换成「被动收」。这正是把耦合从 fan-out=4 压回 2 左右的标准手法。

注意这里是软告警:脚本退出码恒为 0,告警只写进 JSON。产品模块边界允许在运营压力下临时越界,基准的作用是让越界被看见,而不是一刀切禁掉。

归属表怎么和功能树对齐

最后把两张表对齐一遍:功能树的每个 owner 都应该在归属表里作为某份状态的写者出现;归属表里的每个模块也都应该在功能树里拥有至少一个功能。两张表互相印证,模块边界才算真正闭合。

下一步是把「谁能调用谁」也定下来——状态归属解决了「谁有权写」,但还没解决「一个模块怎么请求另一个模块去写」。那就是跨模块契约:用 TypeScript 接口把命令、事件和读写权限一起写死,再用 node --test 断言「同一份状态被两个模块写」一定被检出。

阅读导航:上一节:功能树与模块职责 · 下一节:跨模块契约与接口约定 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引