-- ============================================================================ -- TamaBox「加载更多重复加载」根因诊断 v2(按生产真实结构定制) -- 适用库: schema = nekobox,表 = nekobox.questions / nekobox.users -- questions: id numeric, created_at/updated_at/deleted_at timestamp(6) WITHOUT time zone, -- user_id bigint, answer text, is_private/is_pinned/is_read boolean NOT NULL -- users: id numeric, domain text, ... -- 脚本用途: 用纯 SQL 复刻 ListAPI(FilterAnswered+PrivateStatus=1+PinFirst, 每页20)的 -- 复合键集翻页,判定「DB 层数据自洽性」+「时区解释是否与线上游标一致」。 -- 覆盖范围: 只读查询 + 会话级临时表,无任何持久写。可放心在生产执行。 -- 使用: psql/pgAdmin/DBeaver 整段执行;若当前库 schema 不是 nekobox,改开头 SET 一行。 -- 判读: 见文件末尾说明;跑完把「查询结果 + NOTICE」整体贴回。 -- ============================================================================ -- ============ 0) 切换 schema(关键:默认 search_path 只有 public,所以之前找不到表) ============ SET search_path TO nekobox, public; SELECT '连接确认' AS step, current_database() AS db, current_schema() AS effective_schema, -- 应显示 nekobox current_setting('TimeZone') AS session_tz; -- ============================================================================ -- PART A 环境 / 结构 / 目标集合核对 -- ============================================================================ SELECT 'PART A: 环境与结构核对' AS step; -- 1) questions 关键列定义(确认 id 为 numeric、created_at 无时区精度6) SELECT column_name, data_type, datetime_precision, is_nullable, column_default FROM information_schema.columns WHERE table_schema = 'nekobox' AND table_name = 'questions' AND column_name IN ('id','created_at','updated_at','deleted_at','user_id', 'answer','is_private','is_pinned','is_read') ORDER BY ordinal_position; -- 2) user_id 相关索引(确认是否命中 idx_questions_user_private_created 复合索引) SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'nekobox' AND tablename = 'questions' AND indexdef ILIKE '%user_id%'; -- 3) aoi 箱主定位 + 集合核对(answered_public_all 应=线上 API 总数,曾为 38) SELECT u.id AS aoi_user_id, u.domain, count(*) FILTER (WHERE q.answer IS NOT NULL AND q.answer <> '' AND q.is_private = false) AS answered_public_all, count(*) FILTER (WHERE q.answer IS NOT NULL AND q.answer <> '' AND q.is_private = false AND q.is_pinned) AS answered_public_pinned, count(*) AS all_questions_rows FROM users u LEFT JOIN questions q ON q.user_id = u.id AND q.deleted_at IS NULL WHERE u.domain = 'aoi' GROUP BY u.id, u.domain; -- 4) 该集合最新 6 条(含被删/私密剔除样本),关键看 created_at 的「墙钟」写法 -- -> 与线上 API JSON 的 created_at(如 2026-09-05T00:49:38.648502Z) 逐位比对: -- 若 DB 墙钟==线上 Z 时间文本 => 读写按 UTC 对称(无时区偏移) -- 若 DB 墙钟比线上 Z 大 8 小时 => 时区偏移实锤(上海墙钟被当 UTC / 反之) SELECT q.id, q.is_pinned, q.created_at AS db_wall_clock, -- 无时区列原值 to_char(q.created_at, 'YYYY-MM-DD"T"HH24:MI:SS.US') AS db_wall_text, (extract(epoch FROM q.created_at) * 1000000000)::numeric(30,0) AS nano_if_wall_is_utc, q.answer <> '' AS has_answer, q.is_private AS is_private, q.deleted_at IS NOT NULL AS is_deleted FROM questions q WHERE q.user_id = (SELECT id FROM users WHERE domain = 'aoi') ORDER BY q.is_pinned DESC, q.created_at DESC, q.id DESC LIMIT 6; -- 5) 是否存在 created_at 完全相同的行(同刻多行若缺 id tiebreaker 会导致翻页跳行/重复) SELECT q.created_at, count(*) AS n FROM questions q WHERE q.user_id = (SELECT id FROM users WHERE domain = 'aoi') AND q.deleted_at IS NULL AND q.answer <> '' AND q.is_private = false GROUP BY q.created_at HAVING count(*) > 1 ORDER BY n DESC LIMIT 10; -- ============================================================================ -- PART B 纯 SQL 复刻 ListAPI 复合键集翻页(核心复现,DB 层数据自洽性判定) -- 游标锚点直接用 DB 行自身的 created_at(墙钟) 值推进——不经过任何 Go/时区转换, -- 专门回答: 「库里的数据 + 墙钟 keyset 排序」本身能否正确翻页、不重不漏。 -- ============================================================================ SELECT 'PART B: keyset 翻页复现' AS step; DROP TABLE IF EXISTS pg_temp.diag_pages; DO $$ DECLARE v_uid numeric; -- users.id 也是 numeric v_page int := 0; v_rows int; v_ids text; v_prev_ids text := ''; v_pin boolean; -- 上一页末条锚点 v_ts timestamp; -- 上一页末条锚点(用 timestamp 变量,避免任何时区换算) v_id numeric; v_last_pin boolean; v_last_ts timestamp; v_last_id numeric; r record; BEGIN SELECT id INTO v_uid FROM users WHERE domain = 'aoi' LIMIT 1; IF v_uid IS NULL THEN RAISE EXCEPTION 'users 中不存在 domain=aoi,请把脚本中的 aoi 换成你的箱 domain'; END IF; CREATE TEMP TABLE IF NOT EXISTS diag_pages (page_no int, n_rows int, ids text); DELETE FROM diag_pages; LOOP v_page := v_page + 1; v_ids := ''; v_rows := 0; v_last_pin := NULL; v_last_ts := NULL; v_last_id := NULL; FOR r IN SELECT q.id, q.is_pinned, q.created_at FROM questions q WHERE q.user_id = v_uid AND q.deleted_at IS NULL AND q.answer IS NOT NULL AND q.answer <> '' AND q.is_private = false AND ( CASE WHEN v_page = 1 THEN TRUE -- 首页无游标 WHEN v_pin THEN -- 锚点在置顶段: 置顶段内更小 + 全部非置顶 (q.is_pinned = true AND (q.created_at < v_ts OR (q.created_at = v_ts AND q.id < v_id))) OR q.is_pinned = false ELSE -- 锚点在非置顶段: 非置顶段内更小 q.is_pinned = false AND (q.created_at < v_ts OR (q.created_at = v_ts AND q.id < v_id)) END ) ORDER BY q.is_pinned DESC, q.created_at DESC, q.id DESC LIMIT 20 LOOP v_rows := v_rows + 1; v_ids := v_ids || r.id::text || ','; v_last_pin := r.is_pinned; v_last_ts := r.created_at; v_last_id := r.id; END LOOP; IF v_rows = 0 THEN RAISE NOTICE '第 % 页: 0 行 -> 翻页正常结束(空页,等价 has_more=false)', v_page; EXIT; END IF; v_ids := left(v_ids, -1); INSERT INTO diag_pages VALUES (v_page, v_rows, v_ids); RAISE NOTICE '第 % 页: % 行 ids=[%]', v_page, v_rows, v_ids; IF v_ids = v_prev_ids THEN RAISE NOTICE '!!! 异常: 第 % 页与第 % 页完全重复,游标未前进(正是线上 bug 症状)', v_page, v_page - 1; EXIT; END IF; v_prev_ids := v_ids; IF v_rows < 20 THEN RAISE NOTICE '本页不足 20 行 -> 已到末页(等价 has_more=false),翻页完成'; EXIT; END IF; IF v_page >= 30 THEN RAISE NOTICE '!!! 异常: 已达 30 页上限仍未收敛,疑似死循环'; EXIT; END IF; v_pin := v_last_pin; v_ts := v_last_ts; v_id := v_last_id; END LOOP; RAISE NOTICE '--- 汇总: 共 % 页 ---', (SELECT count(*) FROM diag_pages); END $$; -- 表格形式再输出一次(NOTICE 显示不全时看这里) SELECT page_no, n_rows, ids FROM diag_pages ORDER BY page_no; -- 跨页重复检测: 无重复时只返回上面提示 SELECT '跨页重复检测: 若下方无第二张表结果则无重复' AS check_msg; SELECT unnest(string_to_array(ids, ','))::numeric AS duplicated_question_id FROM diag_pages GROUP BY 1 HAVING count(*) > 1; -- ============================================================================ -- PART C 时区/游标指纹核对 —— 区分「部署版本旧」vs「无时区列+时区解释错位」 -- 线上游标形如 0.1788566476678885000.3089(= pin=0, nano=1788566476678885000, id=3089) -- 判读: 下面第 1 条若能精确命中 DB 行(3089) => 服务端按「DB墙钟==UTC」生成游标, -- 与库值一致 => 无时区错位,问题指向部署版本/API 层; -- 若命中不了 => nano 与实际库值对不上,时区解释有偏差。 -- 用法: 把 nano 数字换成你线上抓到的 next_cursor 中间段;id 换成对应行。 -- ============================================================================ SELECT 'PART C: 时区/游标指纹核对' AS step; -- 1) nano 反向推导: 假定「墙钟==UTC」得到的 timestamp,是否正好等于 DB 中 3089 行的 created_at? WITH c(nano) AS (VALUES (1788566476678885000::numeric)) -- <-- 替换为线上 next_cursor 中间段 SELECT c.nano, (to_timestamp(c.nano / 1000000000.0) AT TIME ZONE 'UTC')::timestamp(6) AS derived_ts_if_wall_is_utc, (SELECT q.created_at FROM questions q WHERE q.id = 3089) AS actual_db_ts_of_id_3089, ((to_timestamp(c.nano / 1000000000.0) AT TIME ZONE 'UTC')::timestamp(6) = (SELECT q.created_at FROM questions q WHERE q.id = 3089)) AS match_3089 FROM c; -- 2) 命中检测(通用版): nano 推导出的墙钟时刻在库中对应哪些行(正常应含线上游标那行) WITH c(nano) AS (VALUES (1788566476678885000::numeric)) -- <-- 同上 SELECT q.id, q.created_at FROM questions q, c WHERE q.user_id = (SELECT id FROM users WHERE domain = 'aoi') AND q.deleted_at IS NULL AND q.answer <> '' AND q.is_private = false AND q.created_at = (to_timestamp(c.nano / 1000000000.0) AT TIME ZONE 'UTC')::timestamp(6) ORDER BY q.id; -- 3) 对照: 库中是否存在「把线上 JSON 的 Z 时刻原样解释为墙钟」对应的行 -- 即验证: 应用写库时是否把 UTC 时刻的墙钟数字直接存入无时区列(读写对称、无偏移) SELECT q.id, q.created_at FROM questions q WHERE q.user_id = (SELECT id FROM users WHERE domain = 'aoi') AND q.deleted_at IS NULL AND q.answer <> '' AND q.is_private = false AND q.created_at IN ('2026-09-05 00:49:38.648502', '2026-09-05 00:01:16.678885') -- <-- 替换为线上首页首尾条的 created_at 文本 ORDER BY q.created_at DESC, q.id DESC; -- ============================================================================ -- 判读指引 -- 1) PART B 第1页 id 应=线上首页20条;第2页应翻出剩余且无重复(DB 层自洽) -- 2) PART B 若出现「第N页与第N-1页完全重复」=> 直接用 SQL 在 DB 层复现了 bug -> 数据/排序问题 -- 3) PART C.1 match_3089=true 且 C.3 能命中 => 读写按 UTC 对称,无时区错位, -- DB keyset 正常时问题锁定在【部署版本/API层】;反之 => 无时区列+时区解释错位 -- 4) PART A.4 直接目视: DB 墙钟 vs 线上 JSON 的 Z 文本,差 8 小时即时区偏移实锤 -- ============================================================================