BiSheng 列表 Cursor 翻页与无限滚动:从 OFFSET 到 Keyset 的深翻页性能治理实战(F027)
2026/9/15 12:29:21 网站建设 项目流程

BiSheng 列表 Cursor 翻页与无限滚动:从 OFFSET 到 Keyset 的深翻页性能治理实战(F027)

【免费下载链接】bishengBISHENG is an open LLM devops platform for next generation Enterprise AI applications. Powerful and comprehensive features include: GenAI workflow, RAG, Agent, Unified model management, Evaluation, SFT, Dataset Management, Enterprise-level System Management, Observability and more.项目地址: https://gitcode.com/GitHub_Trending/bi/bisheng

导读:本文是 BiSheng 开源项目高频列表(知识库 / 应用 / 知识空间文件)从page_num + COUNT(*)偏移翻页改造为cursor(keyset)翻页 + 前端无限滚动的完整技术手册。改造的核心驱动力不是 UI 体验,而是根治翻深页时 OpenFGA 细权限请求量随页号线性增长的问题:OFFSET 模式下翻第 50 页要为前 49 页全部资源各跑一次 ReBAC 过滤,cursor 模式下每次只对当前 keyset 窗口跑一次。读完本文,你将掌握 BiSheng 的 cursor envelope 协议、common/cursor.py的 token 编解码、database/utils/keyset.py的 DM8 兼容展开式 WHERE、fetch-until-enough 扫描循环、前端useInfiniteCursorTable/ LoadMore sentinel 的实现,以及全部关键陷阱和新增列表的改造清单。


1. 总览:为什么要把列表翻页从 OFFSET 换成 Cursor

在 OFFSET 翻页模型下,LIMIT 20 OFFSET 980这类查询有两个天然缺陷:

  1. COUNT 开销:为了渲染「共 X 个」,每次都要跑COUNT(*),在 ReBAC 细权限场景下这意味着把全量可见资源数一遍;
  2. 翻深页的权限放大:BiSheng 的列表数据在 DAO 之外还有一层 OpenFGA 细权限过滤。OFFSET 模式下翻第 50 页,数据库要先把前 49 页全部行取出来,每行都要过一次 ReBAC 过滤才能确定哪些「可见」、哪些被过滤掉,才能定位到第 50 页的起点。页号越深,这个开销线性增长。

cursor(keyset)翻页的思路是:不再用「偏移量」定位,而是用「上一页最后一条记录的排序键」定位下一页的起点。每页只查当前 keyset 窗口之后的一小批数据,ReBAC 过滤也只需要针对当前窗口执行一次。翻页成本与页号解耦,恒定为 O(窗口大小)

整体调用链路如下:

┌──────────────────────────────────────────────────────────────────┐ │ 前端 │ │ ┌──────────────┐ ┌───────────────────────┐ │ │ │ useInfinite- │ │ LoadMore sentinel │ │ │ │ CursorTable │ │ (IntersectionObserver)│ │ │ └──────┬───────┘ └───────────┬───────────┘ │ │ │ cursor=next_cursor │ 滚到底自动触发 │ │ ▼ ▼ │ │ GET /api/v1/<list>?cursor=<token>&page_size=20 │ └──────────────────────────┬────────────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 后端 Service │ │ ┌────────────────────────────────────────────────────────────┐ │ │ │ decode_cursor(token, expected_context, expected_key_len) │ │ │ │ 失败 → 抛业务错误码 (10550 / 10991 / 18070) │ │ │ └────────────────────────┬───────────────────────────────────┘ │ │ ▼ │ │ ┌────────────────────────────────────────────────────────────┐ │ │ │ fetch-until-enough scan loop (若细权限过滤可能减少) │ │ │ │ 每轮: DAO.aget_xxx(cursor=batch_cursor, limit=batch) │ │ │ │ 过滤: ApplicationPermissionService.get_app_permission │ │ │ │ 累积: 到 page_size + 1 (探 has_more) 或 DB 拉空 │ │ │ │ 推进: batch_cursor = last DB row (非 last visible) │ │ │ └────────────────────────┬───────────────────────────────────┘ │ │ ▼ │ │ ┌────────────────────────────────────────────────────────────┐ │ │ │ encode_cursor((sort_key_tuple), context) → next_cursor │ │ │ └────────────────────────┬───────────────────────────────────┘ │ └──────────────────────────┬────────────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ DAO + DB (MySQL / DM8) │ │ SELECT ... WHERE <keyset predicate> │ │ ORDER BY <sort_cols> LIMIT <batch+1> │ │ 注: DM8 不支持 row-value tuple 比较,统一走展开 OR ladder │ └──────────────────────────────────────────────────────────────────┘

1.1 改造范围

接口改造内容
GET /api/v1/knowledgeOFFSET → cursor;砍 COUNT;sort_by=name用复合索引(name, id)
GET /api/v1/workflow/listOFFSET → cursor;砍 COUNT;workflow + assistant UNION;fetch-until-enough 循环
GET /api/v1/knowledge/space/{id}/childrenOFFSET → cursor;scan loop 凑够即停;ext_rank 用 CASE WHEN
GET /api/v1/departments/treemember_count字段及对应COUNT(*) GROUP BY
GET /api/v1/tool后端不动,只清前端「共 X 个」文案

注意最后一行的边界:并不是所有接口都要动后端。/api/v1/tool后端保持原样,只清掉前端「共 X 个」文案,因为 tool 列表本身的量级和权限模型不构成深翻页瓶颈——改造是「按需」而非「一刀切」。


2. 协议层:Cursor Envelope

所有 cursor 接口返回统一的响应 envelope,定义在 common/schemas/api.py:

class PageInfiniteCursorData(BaseModel, Generic[T]): data: List[T] page_size: int has_more: bool next_cursor: Optional[str]

与旧PageData[T]的区别

  • total字段——砍 COUNT 是本次性能优化的核心,前端不再依赖总数;
  • has_more替代「是否最后一页」的推断;
  • next_cursorNone时表示已到末页,前端 LoadMore sentinel 应停止触发。

请求侧约定:cursor是 query 参数,空值或省略 = 第一页;page_size用户可控,典型 20。前端拿到next_cursor后,把它作为下一次请求的cursorquery 参数;当has_moreFalse时停止加载。这套契约在 src/frontend/platform/src/util/hook.ts 的类型签名里有镜像声明。

2.1 Cursor token 编解码

common/cursor.py提供两个核心函数:

def encode_cursor(values: Sequence, *, context: str) -> str def decode_cursor(token: str, *, expected_key_len: int, expected_context: str) -> List

token 结构(简化):base64url(json({"v": 1, "s": context, "k": [sort_key_values...]}))。从源码看,实际 payload 有三个字段:

  • v:schema 版本(当前恒为1,见CURSOR_SCHEMA_VERSION = 1),允许未来格式演进而不静默破坏旧 cursor;
  • scontext 签名,例如"flow|sort=update_time""knowledge|sort_by=update_time""space_children|order=file_type_asc"。它是「这个 cursor 是哪个查询发的」的标签,用来防御「用户拿 A 接口的 cursor 喂 B 接口」或「排序条件变了还在用旧 cursor」;
  • k:有序排序键值数组,最后一个元素必须是 id 决胜键(tie-breaker),保证 keyset 比较严格单调。

decode_cursor的解码校验链(common/cursor.py):

  1. 空值 / 空串直接返回None(表示第一页);
  2. 还原被encode_cursor剥掉的 base64 padding("=" * (-len(cursor) % 4)),然后urlsafe_b64decode+json.loads,任何异常包装为CursorDecodeError
  3. payload["v"] != CURSOR_SCHEMA_VERSIONCursorDecodeError
  4. payload["s"] != expected_contextCursorDecodeError(context 不匹配,说明调用方的排序/过滤条件在 cursor 签发之后变了);
  5. payload["k"]非 list 或长度 !=expected_key_lenCursorDecodeError

decode_cursor失败(token 篡改 / context 不匹配 / key 长度对不上)统一抛CursorDecodeError,由 Service/API 层翻译成模块专属业务错误码:

错误码模块异常类含义
10550flow (105)AppInvalidCursorError(common/errcode/flow.py)workflow/app 列表 cursor 解码失败
10991knowledge (109)KnowledgeInvalidCursorError(common/errcode/knowledge.py)知识库列表 cursor 解码失败
18070knowledge_space (180)KnowledgeSpaceInvalidCursorError(common/errcode/knowledge_space.py)空间文件列表 cursor 解码失败

前端拿到这些错误码后必须reset cursor=null重新从第一页拉(useInfiniteCursorTable暴露的reset()正是为此设计,其注释明确列了 10991 / 10550 / 18070 三个码)。

编码侧的 JSON 兼容处理:F027 的排序键经常包含datetimeupdate_time/create_time)。encode_cursor通过_json_defaultfallback 把datetime/date转成 ISO 8601 字符串,否则json.dumps会抛TypeError。解码后 keyset WHERE 直接把 ISO 字符串字面量与列比较,MySQL 和 DM 都会隐式 cast,所以比较语义不变。


3. Keyset WHERE 子句:DM8 兼容的展开式

database/utils/keyset.pybuild_keyset_where()是所有 cursor DAO 的统一 WHERE 子句生成器。SQL-92 标准写法是row-value tuple 比较

WHERE (update_time, id) < (?, ?)

MySQL / Postgres / SQLite 都支持。但DM8 v8 不支持(报[CODE:-2007] line N, column M, nearby [?] has error: Syntax error),即使 T001 的 dialect-stub smoke test 用DefaultDialect编译能通过——编译能过 ≠ 运行时能跑,这是达梦方言栈最容易踩的坑。

因此模块级开关_USE_EXPANDED_FALLBACK = True必须始终开启(源码注释明确要求:改动前必须在真实 DM8 环境验证)。开启后 helper 自动把 tuple 比较展开成 OR ladder:

WHERE update_time > ? OR (update_time = ? AND id > ?)

语义等价,索引使用一样(复合索引(update_time, id)同样能 seek)。

3.1 混合方向 ASC/DESC

space_children 的 keyset 是file_type ASC, ext_rank ASC, update_time DESC, id DESC—— 混合方向用 tuple 表达不了,必须用展开 OR。helper 接受descending: Sequence[bool]参数,自动按列方向生成><

build_keyset_where( sort_cols=(t.c.file_type, t.c.update_time, t.c.id), cursor_values=(0, dt0, 100), descending=(False, True, True), )

从 keyset.py 的实现看,展开逻辑是标准的 OR ladder 构造:第 i 列生成col_0 = v_0 AND ... AND col_{i-1} = v_{i-1} AND col_i >|< v_i,全部用or_()串起来。descending为单个bool时(所有列同方向),若_USE_EXPANDED_FALLBACK开启同样走展开式;只有未来关闭 fallback 且方向一致时才会退回 SQLAlchemy 的tuple_() < tuple_()紧凑写法。helper 还会校验sort_cols/cursor_values/descending三者长度一致,不一致直接ValueError

3.2 CASE 表达式作 sort_col

knowledge_fileext_rank(扩展名优先级:pdf=1 / docx=2 / ...)是一个 15-WHEN 的 CASE 表达式。helper 接受任意 SQLAlchemyColumnElement,包括case(),所以 cursor 排序键可以是计算值。但这里有一个必须维护的**「双函数对」一致性约束**:

  • SQL 侧:case()表达式算ext_rank,用于 DAO 排序与 keyset WHERE;
  • Python 侧:_compute_ext_rank_python()(定义在 knowledge_space_file.py),用于收到 DAO 一批数据后给最后一行算ext_rank推进batch_cursor

_scan_visible_child_items的 cursor 推进就是典型用法(knowledge_space_service.py):

last_db = batch_items[-1] batch_cursor = [ last_db.file_type, _compute_ext_rank_python(last_db.file_name), last_db.update_time, last_db.id, ]

Python 侧如果和 SQL CASE 错位(比如 CASE WHEN 的优先级顺序改了一边没改另一边),下一批 keyset 边界就会算错,导致漏行或重复——这是改动ext_rank定义时必须同时改两处的原因。


4. Fetch-until-enough Scan Loop

OFFSET 翻页时代不存在「页缺数」问题:细权限把当前页过滤剩 7 条,下一页就是第 N+1 行起步,页永远填满。但 cursor 模式下,如果 service 在 DAO 之后做 ReBAC 细过滤,page_size=20拉来的 20 行过滤后可能只剩 7 条,直接返给前端就是「列表突然短」。

解决套路:在 service 层加循环——DAO 拉一批 → 过滤 → 累积到page_size + 1(探到 has_more)或 DB 拉空才返回。两个真实落地案例:

接口实现batch_size 常量
workflow/listWorkFlowService._scan_visible_flows_cursor(api/services/workflow.py)_FLOW_PERMISSION_SCAN_BATCH_SIZE = 50
knowledge_space/childrenKnowledgeSpaceService._scan_visible_child_items(knowledge_space_service.py)_CHILD_PERMISSION_SCAN_BATCH_SIZE = 100

骨架(伪代码,与_scan_visible_flows_cursor逐行对应):

visible: List[Dict] = [] batch_cursor = decoded_cursor # 从前端 cursor 解出来,或 None 表示第一页 while True: batch, db_has_more = await DAO.fetch(cursor=batch_cursor, limit=BATCH_SIZE) if not batch: return visible[:page_size], False kept = filter_by_fine_grained_permission(batch) for item in kept: visible.append(item) if len(visible) > page_size: return visible[:page_size], True # has_more=True if not db_has_more: return visible[:page_size], False # 关键: cursor 推进用 last DB row,不是 last visible batch_cursor = encode_sort_key_from(batch[-1])

最容易写错的一行batch_cursor = batch[-1]必须用 DAO 返回的最后一行(last DB row),不能用过滤后的最后一行(last visible)。源码中两处实现都带着同样的注释警告:

"Advance batch_cursor to the LAST DB row of this batch (not last visible) so the next batch picks up strictly after; if we used the last visible, items filtered out between them would be re-emitted on the next batch."

如果误用 last visible,被过滤掉的中间行会落在 keyset 的「严格大于」边界之内,下一批会被 DAO 重新返出来,最终重复累积进visible,造成列表出现重复条目且永无尽头。

4.1 不同接口的过滤位置差异

三条 cursor 线的 OpenFGA 过滤策略不同,决定了是否需要 scan loop:

接口过滤位置是否需要 scan loop
knowledgeDB 之前PermissionService.list_accessible_ids()一次拉出可见 id 集,作为 DAOWHERE id IN (...)条件否,DB 拉多少 = 返多少
workflow/listDB 前粗筛 + DB 后细筛:粗筛只看类型维度(view_app/edit_app),DAO 后对结果再跑get_app_permission_map_async是,因为细筛可能缩水
knowledge_space/childrenDB 后逐批过滤_build_child_permission_context+ per-item check是,且过滤率可能 > 50%

knowledge 走「先算清楚再查」,代价是首次进入要并发跑全集 ReBAC,但走 Redis 缓存基本毫秒级;workflow / space_children 走「先查再过滤」,所以必须 fetch-until-enough。这个取舍在源码里还有一条补充:workflow 的 scan loop 还会跨批次聚合writeable_ids,保证响应里的can_write标志不受批次边界影响(workflow.py)。

另外注意:workflow 的next_cursor编码用的是last["update_time"], last["id"],但 workflow/list 是workflow(int id)与 assistant(UUID 字符串 id)的 UNION。源码注释解释了为何这里不会崩:encode_cursor不强转类型,JSON 保留原类型(int 和 str 都能序列化),keyset WHERE 与sub_query.c.id列比较时由 SQLAlchemy 的 literal binding 吸收两种类型。如果哪个环节手贱写了int(last['id']),UNION 里遇到 UUID 行就会抛ValueError——这正是「关键陷阱速查」表里那一行的由来。


5. 前端模式

5.1 Platform:useInfiniteCursorTable

复用 hook src/frontend/platform/src/util/hook.ts:

const { data, hasMore, loading, reload, loadMore } = useInfiniteCursorTable({ queryFn: ({ cursor }) => getKnowledgeList({ cursor, page_size: 20, ...filters }), deps: [searchText, sortBy], // 这些变化时自动 reload(reset cursor=null) })

hook 内部维护nextCursor/accumulated data;调用方只暴露datahasMoreloadMore()。源码里的实现细节很值得注意:

  • requestIdRef序列化在途请求:每次loadPage自增requestIdRef.current,响应回来时若reqId !== requestIdRef.current则丢弃,保证并发下只有最新一次请求的结果生效,避免旧响应覆盖新数据;
  • loadMore三重守卫loading || !hasMore || !cursor任一成立就 BLOCKED(源码里有console.log('[useInfiniteCursorTable] loadMore BLOCKED by guard')),防止重复触发与末页空转;
  • filterData(params)语义对齐旧useTable:把任意过滤参数 merge 进paramsRef后从第一页重载,调用方换 hook 时无需改下拉框处理逻辑;
  • refreshData(predicate, patch):按谓词匹配行并局部 merge patch,对齐旧 hook 的 mutation 调用点;
  • reset():清空 keyword 并从第一页重载,供*InvalidCursorError场景使用。

搜索/过滤/排序变化时search()/filterData()都会把 cursor 重置为nullloadPage(null, false)),因为旧的 cursor 的 context 签名已经不再匹配。

5.2 Client:useFileManager

Client 没有通用 hook(useFileManager.ts是 SpaceDetail 专用,src/frontend/client/src/pages/knowledge/hooks/useFileManager.ts)。useFileManager把「page 1 替换、page>1 append」「默认路径用 nextCursor、搜索路径用 nextSearchPage 拼接」合在loadFiles(page)一个方法里,外部用onPageChange(currentPage + 1)触发下一批。

5.3 LoadMore sentinel

src/frontend/platform/src/components/bs-comp/loadMore/index.tsx 和 src/frontend/client/src/pages/knowledge/SpaceDetail/LoadMore.tsx 是同一模式的两个版本。核心实现:

const sentinelRef = useRef<HTMLDivElement>(null) useEffect(() => { const root = findScrollableAncestor(sentinelRef.current) // ↑ 必须传 root,否则容器内滚动不触发 const observer = new IntersectionObserver((entries) => { if (entries[0].isIntersecting) onLoadRef.current?.() }, { root, threshold: 0.1 }) observer.observe(sentinelRef.current) return () => observer.disconnect() }, [])

两个最坑的陷阱

  1. IntersectionObserverroot: null默认走 viewport。BiSheng 大部分列表是「列表区在固定高度容器里 overflow:scroll」,容器内滚动不改变 sentinel 跟 viewport 的关系 → observer 只在 mount 时触发一次,之后永远不再触发。必须用findScrollableAncestor()走 DOM 找最近overflow-y: auto / scroll / overlay祖先作 root。
  2. onLoad闭包冻结 stalenextCursor。observer 是 mount 时创建的([]deps),callback 里用的onLoad是首次渲染时的版本。必须用useRef同步:onLoadRef.current = onLoad每次 render 都更新,observer callback 调onLoadRef.current?.()拿最新版本。

不解决这两个,代码看起来对、第一页加载也对,然后下拉就再也不触发,且没有任何报错——这是无限滚动实现里最典型的「静默失效」。

5.4 短列表「mount 即触发」副作用

如果首屏数据不足以撑满 scroll container,sentinel mount 时就跟 viewport 相交 → 立刻触发一次 LoadMore。如果第二页数据还不满,继续触发 → 直到hasMore=false。这是正确行为(数据够少就该一次全拉),但 UX 上「没滚就在加载」可能让用户疑惑。需要时可加 500ms mount 缓冲期。

5.5 5s 状态轮询不能动 cursor 链

useFileManager在有「处理中文件」时每 5s 轮询刷状态。append 模式下,不能再用loadFiles(currentPage)——那会把累积 files 替换成最新一批,前面累积的尾部全丢,且 cursor 会前进。

正确做法(refreshLoadedStatuses()):

  • 调一次cursor=null, page_size=files.length,拿前 N 条最新数据;
  • idmerge:已加载行用回包覆盖 status / progress 字段;回包里有但本地没有(新上传)append 到头部;本地有但回包没有的不删;
  • nextCursor / hasMore不动

搜索状态下不轮询(搜索结果是「截图」,实时刷状态意义不大且接口语义不同)。


6. 关键陷阱速查

现象根因修法
DM8 报[CODE:-2007] line N nearby [?] Syntax error,SQL 含(col_a, col_b) < (?, ?)DM8 不支持 row-value tuple compare_USE_EXPANDED_FALLBACK = True(已是默认)
Workflow/space_children 列表「页缺数」(每页返 7 条)细权限过滤后没补scan loop,batch_cursor 推进用 last DB row
LoadMore mount 后只触发一次,滚动再不触发IntersectionObserverroot: null默认 viewport,但 sentinel 在 overflow 容器里findScrollableAncestor()找最近 scroll 祖先作 root
LoadMore 触发但onLoad用的是首次 render 的 cursor[]deps 的 useEffect 闭包冻结了 onLoaduseRef同步:onLoadRef.current = onLoad每 render
Client SpaceDetail 跳到第 5 页拿到第 2 页数据cursor: page > 1 ? nextCursor : null中 nextCursor 只是「下一页」的 cursor,跨页跳无中间历史不允许跳页:UI 改成 LoadMore append 即可
5s 轮询把无限滚动列表「截短」回首页轮询调loadFiles(currentPage)替换了累积数据改成refreshLoadedStatuses(),只 merge status,不动 cursor 链
int(last['id'])抛 ValueErrorworkflow/list UNION:flow id 是 int,assistant id 是 UUID 字符串encode_cursor不强转类型,JSON 保留原类型
datetime is not JSON serializableupdate_time是 datetime,cursor 编码崩encode_cursor加 datetime → ISO 字符串 fallback
部署 backend 镜像时拉不到dataelement/bisheng-backend:base.v8base image 在 docker.io 上 403,cr.dataelem.com 上没有Dockerfile.beta3FROM cr.dataelem.com/dataelement/bisheng-backend:feat_2.6.0-beta2+COPY ./ ./增量构建

最后一行是 F027 期间的部署侧教训:base image 在 docker.io 上被 403 拒绝,而内部镜像仓库 cr.dataelem.com 上没有该 tag,解决方式是改用内部可用的feat_2.6.0-beta2作为基础镜像并增量 COPY 代码。这提醒我们:改造上线时镜像可达性同样要提前验证。


7. 关键文件路径速查

文档体系 - 本文档 → docs/architecture/13-cursor-pagination.md - spec / tasks → features/v2.6.0/027-rebac-list-perf-optim/{spec,tasks}.md - release-contract → features/v2.6.0/release-contract.md (F027 entry + INV-6) cursor 编解码 → src/backend/bisheng/common/cursor.py keyset WHERE → src/backend/bisheng/database/utils/keyset.py (_USE_EXPANDED_FALLBACK = True) envelope → src/backend/bisheng/common/schemas/api.py (PageInfiniteCursorData) errcodes → src/backend/bisheng/common/errcode/{knowledge,flow,knowledge_space}.py 10550 / 10991 / 18070 后端 cursor 实现 - knowledge → src/backend/bisheng/knowledge/domain/services/knowledge_service.py - workflow → src/backend/bisheng/api/services/workflow.py _scan_visible_flows_cursor (fetch-until-enough) get_all_flows_envelope - space_children → src/backend/bisheng/knowledge/domain/services/knowledge_space_service.py _scan_visible_child_items (fetch-until-enough) list_space_children _compute_ext_rank_python (SQL CASE 的 Python 等价) - departments tree → src/backend/bisheng/department/domain/services/department_service.py (member_count 已移除) 前端 platform - hook → src/frontend/platform/src/util/hook.ts → useInfiniteCursorTable - LoadMore → src/frontend/platform/src/components/bs-comp/loadMore/index.tsx - 入口 → pages/BuildPage/apps.tsx pages/KnowledgePage/KnowledgeFile.tsx (兼 /build/knowledge 和 ?type=1 QA 库) 前端 client - hook → src/frontend/client/src/pages/knowledge/hooks/useFileManager.ts - LoadMore → src/frontend/client/src/pages/knowledge/SpaceDetail/LoadMore.tsx - 入口 → src/frontend/client/src/pages/knowledge/SpaceDetail/index.tsx 测试 - cursor 编解码 → src/backend/test/common/test_cursor.py - keyset DAO → src/backend/test/database/test_keyset.py - knowledge cursor → src/backend/test/knowledge/test_knowledge_list_cursor.py - workflow cursor → src/backend/test/api/test_workflow_list_cursor.py - space children → src/backend/test/knowledge/test_knowledge_space_children_cursor.py - 部门树 → src/backend/test/department/test_department_tree_no_member_count.py - client SpaceDetail → src/frontend/client/src/pages/knowledge/hooks/useFileManager.test.ts

8. 给「下一个改这块的人」的清单

要新加一个「列表 X」走 cursor + 无限滚动,按以下七步走:

  1. DAO 层:把现有query_xxx(page, page_size)改成query_xxx(cursor, limit),WHERE 加build_keyset_where(sort_cols, cursor)cursor is None时跳过,即首页不加 keyset 谓词——helper 本身不特判None,调用方要在首页省略谓词),fetch_limit = limit + 1探 has_more。返回(data, has_more)不返 total
  2. Service 层:加xxx_envelope()decode_cursor → fetch-until-enough(如果有细权限过滤)→ encode_cursor(last visible) → PageInfiniteCursorData
  3. Endpointcursor: Optional[str] = Query(None)+page_size: int = Query(20),return envelope。
  4. errcode:在所属模块errcode/<module>.py加一个<XxxInvalidCursorError>(5 位 MMMEE,格式参照10550的 flow 模块),context 字符串配套(例如"xxx|sort=update_time")。
  5. 索引:评估是否需要新加复合索引(sort_col_1, ..., id),DM8 + MySQL 双方言验证(alembic migration 注意dialect_helpers)。
  6. 前端:platform 用useInfiniteCursorTable一行接;client 仿照useFileManager.ts写 hook +<LoadMore>sentinel(注意findScrollableAncestoronLoadRef两个坑)。
  7. 测试:单元测 envelope 路径(mock DAO 测 cursor 解码 / encode / has_more);单元 / 静态测覆盖 fetch-until-enough 循环存在。

实施前先读 spec(features/v2.6.0/027-rebac-list-perf-optim/spec.md)的 AD 节,里面沉淀了 F027 期间的 architectural decisions,包括为什么选 keyset(update_time, id)而不是其他组合为什么 file_type 排序要用 ext_rank 复合 cursor(AD-14)、以及 name 排序为何走「伪 cursor」(内部 offset,AD-15,cursor key 是[page_num]knowledge_service.pylist_knowledgeoffset_scan = sort_by == "name"分支就是它的实现)等关键决策。


9. 设计权衡小结

维度OFFSET 翻页Cursor(keyset)翻页
深翻页成本随页号线性增长(每页都要重扫 + ReBAC 过滤前页)恒定 O(窗口大小),与页号解耦
总数统计依赖COUNT(*)(大表 + ReBAC 场景昂贵)彻底砍掉total,用has_more替代
数据一致性翻页期间插入/删除会导致错位/重复keyset 边界严格单调,天然免疫插入错位
随机跳页支持不支持(UI 只能无限滚动 append)
细权限过滤定位页号需过滤前 N 页全部行每页只需过滤当前窗口
数据库兼容所有方言DM8 需展开 OR ladder,混合方向必须展开

这套模式并非银弹:cursor 翻页牺牲了随机跳页能力,且next_cursor与排序/过滤条件强绑定(context 签名就是为此设计的防御)。但对 BiSheng 的高频列表场景——用户从第一页顺序往下滚、权限过滤发生在 DAO 之后、深翻页访问频繁——它是把 ReBAC 请求量从「随页号线性」压回「恒定窗口」的正确取舍,也是 F027 的核心收益来源。

【免费下载链接】bishengBISHENG is an open LLM devops platform for next generation Enterprise AI applications. Powerful and comprehensive features include: GenAI workflow, RAG, Agent, Unified model management, Evaluation, SFT, Dataset Management, Enterprise-level System Management, Observability and more.项目地址: https://gitcode.com/GitHub_Trending/bi/bisheng

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询