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

📅 发布时间:2026/9/15 12:29:25
BiSheng 列表 Cursor 翻页与无限滚动:从 OFFSET 到 Keyset 的深翻页性能治理实战(F027)
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(*)偏移翻页改造为cursorkeyset翻页 前端无限滚动的完整技术手册。改造的核心驱动力不是 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这类查询有两个天然缺陷COUNT 开销为了渲染「共 X 个」每次都要跑COUNT(*)在 ReBAC 细权限场景下这意味着把全量可见资源数一遍翻深页的权限放大BiSheng 的列表数据在 DAO 之外还有一层 OpenFGA 细权限过滤。OFFSET 模式下翻第 50 页数据库要先把前 49 页全部行取出来每行都要过一次 ReBAC 过滤才能确定哪些「可见」、哪些被过滤掉才能定位到第 50 页的起点。页号越深这个开销线性增长。cursorkeyset翻页的思路是不再用「偏移量」定位而是用「上一页最后一条记录的排序键」定位下一页的起点。每页只查当前 keyset 窗口之后的一小批数据ReBAC 过滤也只需要针对当前窗口执行一次。翻页成本与页号解耦恒定为 O(窗口大小)。整体调用链路如下┌──────────────────────────────────────────────────────────────────┐ │ 前端 │ │ ┌──────────────┐ ┌───────────────────────┐ │ │ │ useInfinite- │ │ LoadMore sentinel │ │ │ │ CursorTable │ │ (IntersectionObserver)│ │ │ └──────┬───────┘ └───────────┬───────────┘ │ │ │ cursornext_cursor │ 滚到底自动触发 │ │ ▼ ▼ │ │ GET /api/v1/list?cursortokenpage_size20 │ └──────────────────────────┬────────────────────────────────────────┘ ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 后端 Service │ │ ┌────────────────────────────────────────────────────────────┐ │ │ │ decode_cursor(token, expected_context, expected_key_len) │ │ │ │ 失败 → 抛业务错误码 (10550 / 10991 / 18070) │ │ │ └────────────────────────┬───────────────────────────────────┘ │ │ ▼ │ │ ┌────────────────────────────────────────────────────────────┐ │ │ │ fetch-until-enough scan loop (若细权限过滤可能减少) │ │ │ │ 每轮: DAO.aget_xxx(cursorbatch_cursor, limitbatch) │ │ │ │ 过滤: 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 batch1 │ │ 注: DM8 不支持 row-value tuple 比较,统一走展开 OR ladder │ └──────────────────────────────────────────────────────────────────┘1.1 改造范围接口改造内容GET /api/v1/knowledgeOFFSET → cursor砍 COUNTsort_byname用复合索引(name, id)GET /api/v1/workflow/listOFFSET → cursor砍 COUNTworkflow assistant UNIONfetch-until-enough 循环GET /api/v1/knowledge/space/{id}/childrenOFFSET → cursorscan loop 凑够即停ext_rank 用 CASE WHENGET /api/v1/departments/tree删member_count字段及对应COUNT(*) GROUP BYGET /api/v1/tool后端不动只清前端「共 X 个」文案注意最后一行的边界并不是所有接口都要动后端。/api/v1/tool后端保持原样只清掉前端「共 X 个」文案因为 tool 列表本身的量级和权限模型不构成深翻页瓶颈——改造是「按需」而非「一刀切」。2. 协议层Cursor Envelope所有 cursor 接口返回统一的响应 envelope定义在 common/schemas/api.pyclass PageInfiniteCursorData(BaseModel, Generic[T]): data: List[T] page_size: int has_more: bool next_cursor: Optional[str]与旧PageData[T]的区别无total字段——砍 COUNT 是本次性能优化的核心前端不再依赖总数用has_more替代「是否最后一页」的推断next_cursor为None时表示已到末页前端 LoadMore sentinel 应停止触发。请求侧约定cursor是 query 参数空值或省略 第一页page_size用户可控典型 20。前端拿到next_cursor后把它作为下一次请求的cursorquery 参数当has_more为False时停止加载。这套契约在 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) - Listtoken 结构简化base64url(json({v: 1, s: context, k: [sort_key_values...]}))。从源码看实际 payload 有三个字段vschema 版本当前恒为1见CURSOR_SCHEMA_VERSION 1允许未来格式演进而不静默破坏旧 cursorscontext 签名例如flow|sortupdate_time、knowledge|sort_byupdate_time、space_children|orderfile_type_asc。它是「这个 cursor 是哪个查询发的」的标签用来防御「用户拿 A 接口的 cursor 喂 B 接口」或「排序条件变了还在用旧 cursor」k有序排序键值数组最后一个元素必须是 id 决胜键tie-breaker保证 keyset 比较严格单调。decode_cursor的解码校验链common/cursor.py空值 / 空串直接返回None表示第一页还原被encode_cursor剥掉的 base64 padding * (-len(cursor) % 4)然后urlsafe_b64decodejson.loads任何异常包装为CursorDecodeErrorpayload[v] ! CURSOR_SCHEMA_VERSION→CursorDecodeErrorpayload[s] ! expected_context→CursorDecodeErrorcontext 不匹配说明调用方的排序/过滤条件在 cursor 签发之后变了payload[k]非 list 或长度 !expected_key_len→CursorDecodeError。decode_cursor失败token 篡改 / context 不匹配 / key 长度对不上统一抛CursorDecodeError由 Service/API 层翻译成模块专属业务错误码错误码模块异常类含义10550flow (105)AppInvalidCursorErrorcommon/errcode/flow.pyworkflow/app 列表 cursor 解码失败10991knowledge (109)KnowledgeInvalidCursorErrorcommon/errcode/knowledge.py知识库列表 cursor 解码失败18070knowledge_space (180)KnowledgeSpaceInvalidCursorErrorcommon/errcode/knowledge_space.py空间文件列表 cursor 解码失败前端拿到这些错误码后必须reset cursornull重新从第一页拉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.py的build_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 ladderWHERE update_time ? OR (update_time ? AND id ?)语义等价索引使用一样复合索引(update_time, id)同样能 seek。3.1 混合方向 ASC/DESCspace_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_colknowledge_file的ext_rank扩展名优先级pdf1 / docx2 / ...是一个 15-WHEN 的 CASE 表达式。helper 接受任意 SQLAlchemyColumnElement包括case()所以 cursor 排序键可以是计算值。但这里有一个必须维护的**「双函数对」一致性约束**SQL 侧case()表达式算ext_rank用于 DAO 排序与 keyset WHEREPython 侧_compute_ext_rank_python()定义在 knowledge_space_file.py用于收到 DAO 一批数据后给最后一行算ext_rank推进batch_cursor。_scan_visible_child_items的 cursor 推进就是典型用法knowledge_space_service.pylast_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 LoopOFFSET 翻页时代不存在「页缺数」问题细权限把当前页过滤剩 7 条下一页就是第 N1 行起步页永远填满。但 cursor 模式下如果 service 在 DAO 之后做 ReBAC 细过滤page_size20拉来的 20 行过滤后可能只剩 7 条直接返给前端就是「列表突然短」。解决套路在 service 层加循环——DAO 拉一批 → 过滤 → 累积到page_size 1探到 has_more或 DB 拉空才返回。两个真实落地案例接口实现batch_size 常量workflow/listWorkFlowService._scan_visible_flows_cursorapi/services/workflow.py_FLOW_PERMISSION_SCAN_BATCH_SIZE 50knowledge_space/childrenKnowledgeSpaceService._scan_visible_child_itemsknowledge_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(cursorbatch_cursor, limitBATCH_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_moreTrue 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 loopknowledgeDB 之前PermissionService.list_accessible_ids()一次拉出可见 id 集作为 DAOWHERE id IN (...)条件否DB 拉多少 返多少workflow/listDB 前粗筛 DB 后细筛粗筛只看类型维度view_app/edit_appDAO 后对结果再跑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 是workflowint id与 assistantUUID 字符串 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 PlatformuseInfiniteCursorTable复用 hook src/frontend/platform/src/util/hook.tsconst { data, hasMore, loading, reload, loadMore } useInfiniteCursorTable({ queryFn: ({ cursor }) getKnowledgeList({ cursor, page_size: 20, ...filters }), deps: [searchText, sortBy], // 这些变化时自动 reload(reset cursornull) })hook 内部维护nextCursor/accumulated data调用方只暴露data、hasMore、loadMore()。源码里的实现细节很值得注意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 ClientuseFileManagerClient 没有通用 hookuseFileManager.ts是 SpaceDetail 专用src/frontend/client/src/pages/knowledge/hooks/useFileManager.ts。useFileManager把「page 1 替换、page1 append」「默认路径用 nextCursor、搜索路径用 nextSearchPage 拼接」合在loadFiles(page)一个方法里外部用onPageChange(currentPage 1)触发下一批。5.3 LoadMore sentinelsrc/frontend/platform/src/components/bs-comp/loadMore/index.tsx 和 src/frontend/client/src/pages/knowledge/SpaceDetail/LoadMore.tsx 是同一模式的两个版本。核心实现const sentinelRef useRefHTMLDivElement(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() }, [])两个最坑的陷阱IntersectionObserverroot: null默认走 viewport。BiSheng 大部分列表是「列表区在固定高度容器里 overflow:scroll」容器内滚动不改变 sentinel 跟 viewport 的关系 → observer 只在 mount 时触发一次之后永远不再触发。必须用findScrollableAncestor()走 DOM 找最近overflow-y: auto / scroll / overlay祖先作 root。onLoad闭包冻结 stalenextCursor。observer 是 mount 时创建的[]depscallback 里用的onLoad是首次渲染时的版本。必须用useRef同步onLoadRef.current onLoad每次 render 都更新observer callback 调onLoadRef.current?.()拿最新版本。不解决这两个代码看起来对、第一页加载也对然后下拉就再也不触发且没有任何报错——这是无限滚动实现里最典型的「静默失效」。5.4 短列表「mount 即触发」副作用如果首屏数据不足以撑满 scroll containersentinel mount 时就跟 viewport 相交 → 立刻触发一次 LoadMore。如果第二页数据还不满继续触发 → 直到hasMorefalse。这是正确行为数据够少就该一次全拉但 UX 上「没滚就在加载」可能让用户疑惑。需要时可加 500ms mount 缓冲期。5.5 5s 状态轮询不能动 cursor 链useFileManager在有「处理中文件」时每 5s 轮询刷状态。append 模式下不能再用loadFiles(currentPage)——那会把累积 files 替换成最新一批前面累积的尾部全丢且 cursor 会前进。正确做法refreshLoadedStatuses()调一次cursornull, page_sizefiles.length拿前 N 条最新数据按idmerge已加载行用回包覆盖 status / progress 字段回包里有但本地没有新上传append 到头部本地有但回包没有的不删nextCursor / hasMore不动。搜索状态下不轮询搜索结果是「截图」实时刷状态意义不大且接口语义不同。6. 关键陷阱速查现象根因修法DM8 报[CODE:-2007] line N nearby [?] Syntax errorSQL 含(col_a, col_b) (?, ?)DM8 不支持 row-value tuple compare_USE_EXPANDED_FALLBACK True已是默认Workflow/space_children 列表「页缺数」每页返 7 条细权限过滤后没补scan loopbatch_cursor 推进用 last DB rowLoadMore mount 后只触发一次滚动再不触发IntersectionObserverroot: null默认 viewport但 sentinel 在 overflow 容器里findScrollableAncestor()找最近 scroll 祖先作 rootLoadMore 触发但onLoad用的是首次 render 的 cursor[]deps 的 useEffect 闭包冻结了 onLoaduseRef同步onLoadRef.current onLoad每 renderClient 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 UNIONflow id 是 intassistant id 是 UUID 字符串encode_cursor不强转类型JSON 保留原类型datetime is not JSON serializableupdate_time是 datetimecursor 编码崩encode_cursor加 datetime → ISO 字符串 fallback部署 backend 镜像时拉不到dataelement/bisheng-backend:base.v8base image 在 docker.io 上 403cr.dataelem.com 上没有写Dockerfile.beta3FROM cr.dataelem.com/dataelement/bisheng-backend:feat_2.6.0-beta2COPY ./ ./增量构建最后一行是 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 和 ?type1 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.ts8. 给「下一个改这块的人」的清单要新加一个「列表 X」走 cursor 无限滚动按以下七步走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。Service 层加xxx_envelope()decode_cursor → fetch-until-enough如果有细权限过滤→ encode_cursor(last visible) → PageInfiniteCursorData。Endpointcursor: Optional[str] Query(None)page_size: int Query(20)return envelope。errcode在所属模块errcode/module.py加一个XxxInvalidCursorError5 位 MMMEE格式参照10550的 flow 模块context 字符串配套例如xxx|sortupdate_time。索引评估是否需要新加复合索引(sort_col_1, ..., id)DM8 MySQL 双方言验证alembic migration 注意dialect_helpers。前端platform 用useInfiniteCursorTable一行接client 仿照useFileManager.ts写 hook LoadMoresentinel注意findScrollableAncestor与onLoadRef两个坑。测试单元测 envelope 路径mock DAO 测 cursor 解码 / encode / has_more单元 / 静态测覆盖 fetch-until-enough 循环存在。实施前先读 specfeatures/v2.6.0/027-rebac-list-perf-optim/spec.md的 AD 节里面沉淀了 F027 期间的 architectural decisions包括为什么选 keyset(update_time, id)而不是其他组合、为什么 file_type 排序要用 ext_rank 复合 cursorAD-14、以及 name 排序为何走「伪 cursor」内部 offsetAD-15cursor key 是[page_num]knowledge_service.py的list_knowledge中offset_scan sort_by name分支就是它的实现等关键决策。9. 设计权衡小结维度OFFSET 翻页Cursorkeyset翻页深翻页成本随页号线性增长每页都要重扫 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),仅供参考