Files
tamabox/outputs/pg-cursor-diagnose-v2-nekobox.sql
T
2026-09-08 10:10:44 +08:00

235 lines
11 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- ============================================================================
-- 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 小时即时区偏移实锤
-- ============================================================================