
迁移后第二天,推荐系统的线上日志突然爆发大量JSON parsing error。排查发现,MySQL的JSON字段中存储的某些数值类型数据,在PostgreSQL的JSONB中发生了精度丢失。
MySQL原始数据:
-- MySQL 8.0
CREATE TABLE user_features (
user_id BIGINT PRIMARY KEY,
feature_json JSON
);
INSERT INTO user_features VALUES
(1, '{"embedding": [0.1234567890123456, 0.9876543210987654], "score": 12345678901234567890}');迁移后PostgreSQL查询结果:
-- PostgreSQL 15.3(使用pgloader默认迁移)
SELECT user_id, feature_json->>'embedding' as embedding_str,
(feature_json->>'score')::BIGINT as score
FROM user_features
WHERE user_id = 1;
-- 结果:
-- user_id | embedding_str | score
-- ---------+--------------------------------------------------+-------
-- 1 | [0.1234567890123456, 0.9876543210987654] | 12345678901234567168 -- 注意最后几位变了!影响评估:浮点数精度丢失导致向量相似度计算偏差,推荐准确率下降3.2%;大整数溢出造成用户画像标签错误,影响约2.1%的用户。
MySQL的JSON类型本质上是文本存储,对数值类型不强制转换,保留原始字符串形式。而PostgreSQL的JSONB是二进制结构化存储,会解析数值为NUMERIC或FLOAT8类型:
FLOAT8:IEEE 754双精度,约15-17位有效数字,导致0.1234567890123456精度截断NUMERIC:可变精度,但默认解析可能溢出数据类型对比表:
特性 | MySQL JSON | PostgreSQL JSONB | 风险等级 |
|---|---|---|---|
存储格式 | 文本 | 二进制 | 中 |
数值精度 | 保留原始字符串 | 强制转换为NUMERIC/FLOAT8 | 高 |
查询性能 | 低(需解析) | 高(GIN索引) | 低 |
存储空间 | 大 | 小(压缩) | 低 |
步骤I:迁移前数据审计
#!/usr/bin/env python3
# audit_json_precision.py - 审计JSON字段数值精度
import mysql.connector
import json
import sys
def check_precision_loss(table, json_column):
conn = mysql.connector.connect(
host='mysql-prod.example.com',
user='migration_user',
password='secret',
database='algorithm_db'
)
cursor = conn.cursor(dictionary=True)
cursor.execute(f"SELECT {json_column} FROM {table} LIMIT 10000")
rows = cursor.fetchall()
precision_issues = []
for row in rows:
try:
data = json.loads(row[json_column])
# 检查数组中的浮点数
if 'embedding' in data:
for val in data['embedding']:
if abs(val) < 1e-10: # 接近0的值
continue
# 检查双精度表示是否精确
if abs(val - float(str(val))) > 1e-15:
precision_issues.append({
'type': 'float_precision',
'original': val,
'float_repr': float(str(val))
})
except Exception as e:
print(f"解析失败: {e}", file=sys.stderr)
return precision_issues
# 在迁移前执行
issues = check_precision_loss('user_features', 'feature_json')
print(f"发现{len(issues)}个精度问题")步骤II:迁移时类型映射策略
-- 方案A:对高精度需求字段使用JSON(文本)而非JSONB
CREATE TABLE user_features (
user_id BIGINT PRIMARY KEY,
feature_json JSON, -- 保留文本形式,避免主动解析
feature_jsonb JSONB -- 用于查询加速的冗余字段
);
-- 方案B:迁移脚本中主动转换类型
-- 使用自定义迁移脚本而非pgloader默认配置
COPY (
SELECT
user_id,
feature_json::JSONB, -- 默认转换
-- 对高精度字段特殊处理
(feature_json->>'embedding')::TEXT as embedding_str, -- 保留原始字符串
(feature_json->>'score')::NUMERIC(30,0) as score_numeric -- 指定精度
FROM mysql_user_features
) TO '/tmp/features_converted.csv' WITH (FORMAT csv);
-- 方案C:应用层适配(推荐)
-- 在PostgreSQL中存储为TEXT,应用层按需解析
ALTER TABLE user_features
ALTER COLUMN feature_json TYPE TEXT;
-- 创建透明访问函数
CREATE OR REPLACE FUNCTION get_feature_embedding(p_user_id BIGINT)
RETURNS FLOAT8[] AS $$
DECLARE
raw_json TEXT;
embedding_str TEXT;
BEGIN
SELECT feature_json INTO raw_json FROM user_features WHERE user_id = p_user_id;
embedding_str := (raw_json::JSONB->>'embedding');
-- 应用层保证精度
RETURN STRING_TO_ARRAY(TRANSLATE(embedding_str, '[]', ''), ',')::FLOAT8[];
END;
$$ LANGUAGE plpgsql PARALLEL SAFE;步骤III:应用层防御性编程
# Python应用层精度保护
from decimal import Decimal
import json
def safe_json_deserialize(json_text: str) -> dict:
"""
对PostgreSQL返回的JSON文本进行安全解析
保持高精度数值为Decimal类型
"""
return json.loads(
json_text,
parse_float=lambda x: Decimal(x) # 避免转换为float
)
# 使用示例
embedding = safe_json_deserialize(user_record['feature_json'])['embedding']
# 计算时使用Decimal保持精度,最终转换为numpy.float32前处理检查项 | 检查方法 | 风险等级 | 预防措施 |
|---|---|---|---|
JSON中浮点数精度 |
| 高 | 应用层使用Decimal |
JSON中大整数 |
| 中 | 使用NUMERIC(30,0)存储 |
特殊字符编码 |
| 低 | 迁移前转义Unicode |
-- 迁移前执行的全库JSON审计脚本
DO $$
DECLARE
rec RECORD;
json_tables CURSOR FOR
SELECT table_schema, table_name, column_name, data_type
FROM information_schema.columns
WHERE data_type IN ('json', 'jsonb');
BEGIN
FOR rec IN json_tables LOOP
RAISE NOTICE '审计表: %.%', rec.table_schema, rec.table_name;
EXECUTE format(
'SELECT COUNT(*) FROM %I.%I WHERE %I::TEXT ~ ''^[0-9]{19,}$''',
rec.table_schema, rec.table_name, rec.column_name
);
END LOOP;
END $$;
迁移后第三天,数据同步链路出现大量主键冲突:duplicate key value violates unique constraint。更诡异的是,某些表的自增ID出现了巨大的"空洞":从10001直接跳到100001,导致业务方质疑数据完整性。
MySQL原始表:
-- MySQL
CREATE TABLE model_predictions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
model_id INT,
prediction_result JSON,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO model_predictions (model_id) VALUES (101); -- id=1
INSERT INTO model_predictions (model_id) VALUES (102); -- id=2
DELETE FROM model_predictions WHERE id=2;
INSERT INTO model_predictions (model_id) VALUES (103); -- id=3(MySQL会复用空洞吗?NO!)PostgreSQL迁移后:
-- 使用pgloader默认迁移
CREATE TABLE model_predictions (
id BIGSERIAL PRIMARY KEY, -- 实际创建sequence
model_id INT,
prediction_result JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 批量插入时
INSERT INTO model_predictions (model_id)
SELECT generate_series(1, 10000); -- sequence当前值=10001
-- 数据同步插入
INSERT INTO model_predictions (id, model_id) VALUES (10001, 200); -- 手动指定ID
-- 业务写入
INSERT INTO model_predictions (model_id) VALUES (201); -- sequence nextval=10002?NO!还是从1开始!
-- ERROR: duplicate key value violates unique constraint "model_predictions_pkey"数据对比表:
操作 | MySQL AUTO_INCREMENT | PostgreSQL SERIAL | 风险点 |
|---|---|---|---|
删除记录后插入 | 继续递增,不回填 | Sequence继续递增 | 无差异 |
手动指定ID | 自动调整下一个ID | Sequence不受影响 | 高 |
批量导入后插入 | 自动跳转到max(id)+1 | 继续从sequence当前值走 | 高 |
事务回滚 | ID已消耗不回退 | Sequence不回滚 | 无差异 |
PostgreSQL的SERIAL类型本质是BIGINT字段+SEQUENCE+DEFAULT值的组合:
-- CREATE TABLE ... (id SERIAL) 实际执行:
CREATE SEQUENCE model_predictions_id_seq;
CREATE TABLE model_predictions (
id BIGINT DEFAULT nextval('model_predictions_id_seq') PRIMARY KEY,
...
);关键差异:
INSERT INTO t (id) VALUES (1000)不会将sequence设为1000方案I:基于max(id)的Sequence校准
-- 迁移完成后,对所有SERIAL字段执行
DO $$
DECLARE
rec RECORD;
max_id BIGINT;
BEGIN
FOR rec IN
SELECT
t.relname as table_name,
a.attname as column_name,
s.relname as seq_name
FROM pg_class t
JOIN pg_attribute a ON a.attrelid = t.oid
JOIN pg_depend d ON d.refobjid = t.oid AND d.refobjsubid = a.attnum
JOIN pg_class s ON s.oid = d.objid
WHERE t.relkind = 'r'
AND a.atttypid = 'int8'::regtype
AND d.classid = 'pg_class'::regclass
AND d.refclassid = 'pg_class'::regclass
LOOP
-- 获取表中最大ID
EXECUTE format('SELECT COALESCE(MAX(%I), 0) FROM %I',
rec.column_name, rec.table_name) INTO max_id;
-- 设置Sequence为max(id)+1
EXECUTE format('SELECT setval(%L, %s)',
rec.seq_name, max_id + 1);
RAISE NOTICE '表 % 的Sequence % 已校准为 %',
rec.table_name, rec.seq_name, max_id + 1;
END LOOP;
END $$;方案II:迁移时自动校准(推荐)
#!/usr/bin/env python3
# sync_sequence_after_load.py
import psycopg2
import sys
def sync_sequence(conn, table_name, id_column='id'):
"""同步Sequence当前值到表中max(id)"""
with conn.cursor() as cur:
# 获取Sequence名称
cur.execute("""
SELECT pg_get_serial_sequence(%s, %s)
""", (table_name, id_column))
seq_name = cur.fetchone()[0]
if not seq_name:
print(f"表 {table_name} 没有SERIAL字段")
return
# 获取max(id)
cur.execute(f"SELECT COALESCE(MAX({id_column}), 0) FROM {table_name}")
max_id = cur.fetchone()[0]
# 校准Sequence
cur.execute(f"SELECT setval(%s, %s)", (seq_name, max_id + 1))
# 验证
cur.execute(f"SELECT last_value FROM {seq_name}")
current_val = cur.fetchone()[0]
print(f"{table_name}: max(id)={max_id}, Sequence已设置为{current_val}")
def main():
# 连接PostgreSQL
conn = psycopg2.connect(
host="pg-prod.example.com",
database="algorithm_db",
user="migration_user",
password="secret"
)
# 需要校准的表列表
tables = ['model_predictions', 'user_features', 'experiment_results']
for table in tables:
sync_sequence(conn, table)
conn.commit()
conn.close()
if __name__ == '__main__':
main()方案III:使用COPY命令时保护Sequence
-- 在数据导入期间,临时禁用Sequence
BEGIN;
ALTER TABLE model_predictions ALTER COLUMN id DROP DEFAULT;
-- 执行COPY或批量导入
COPY model_predictions (id, model_id, ...) FROM '/tmp/data.csv';
-- 手动设置Sequence
SELECT setval('model_predictions_id_seq', (SELECT MAX(id) FROM model_predictions));
-- 恢复DEFAULT
ALTER TABLE model_predictions ALTER COLUMN id SET DEFAULT nextval('model_predictions_id_seq');
COMMIT;方案IV:业务层双写兼容(过渡期)
# 在业务代码中增加Sequence占位符
def insert_prediction(model_id, result):
"""
双写兼容逻辑:同时写入MySQL和PostgreSQL
"""
# MySQL侧:依赖AUTO_INCREMENT
mysql_cursor.execute(
"INSERT INTO model_predictions (model_id, result) VALUES (%s, %s)",
(model_id, result)
)
generated_id = mysql_cursor.lastrowid
# PostgreSQL侧:手动指定ID
postgres_cursor.execute(
"INSERT INTO model_predictions (id, model_id, result) VALUES (%s, %s, %s)",
(generated_id, model_id, result)
)
# 确保Sequence不会冲突
postgres_cursor.execute(
"SELECT setval('model_predictions_id_seq', GREATEST(currval('model_predictions_id_seq'), %s))",
(generated_id,)
)
mysql_conn.commit()
postgres_conn.commit()检查步骤 | 执行命令 | 期望值 | 失败处理 |
|---|---|---|---|
序列当前值 |
|
| 执行setval |
自增字段一致性 |
| 两行相同 | 重新校准 |
手动插入风险 |
| 0 | 告警并校准 |
并发写入冲突 |
| 无锁等待 | 调整业务逻辑 |

迁移后,数百个数据分析SQL在PostgreSQL中报错:column "xxx" must appear in the GROUP BY clause or be used in an aggregate function。这在MySQL中运行良好,因为MySQL的sql_mode默认包含ONLY_FULL_GROUP_BY的宽松实现。
MySQL中可以运行的"烂SQL":
-- MySQL 8.0 (sql_mode默认宽松)
SELECT
user_id,
city, -- 不在GROUP BY中,也不在聚合函数中!
COUNT(*) as order_cnt,
MAX(amount) as max_amount
FROM orders
GROUP BY user_id; -- 但MySQL会"随机"返回city值
-- MySQL会返回每个user_id的"某个"city值,通常是分组内的第一条记录PostgreSQL直接报错:
-- PostgreSQL 15.3
SELECT
user_id,
city, -- ERROR: column "orders.city" must appear in the GROUP BY clause...
COUNT(*) as order_cnt,
MAX(amount) as max_amount
FROM orders
GROUP BY user_id;
-- 错误信息:
-- ERROR: column "orders.city" must appear in the GROUP BY clause or be used in an aggregate function
-- LINE 3: city,影响评估:直接导致200+个数据报表SQL失效,BI系统瘫痪2小时;算法团队的特征工程脚本批量报错,模型训练流水线停滞。
SQL标准差异表:
数据库 | GROUP BY严格性 | 非聚合列处理 | 性能影响 | 数据一致性 |
|---|---|---|---|---|
MySQL (默认) | 宽松 | 返回分组内不确定值 | 低 | 差(结果不确定) |
PostgreSQL | 严格 | 强制报错 | 无 | 优(保证确定性) |
Oracle | 宽松 | 需配合MIN/MAX | 中 | 中 |
SQL Server | 严格 | 强制报错 | 无 | 优 |
MySQL的宽松模式虽然"方便",但违反了SQL标准,返回的结果集是不确定的(nondeterministic)。在复制环境下可能导致主从数据不一致。
方案I:使用ANY_VALUE()(MySQL 8.0+)→ STRING_AGG()(PostgreSQL)
-- MySQL原始查询
SELECT user_id, ANY_VALUE(city) as city, COUNT(*)
FROM orders GROUP BY user_id;
-- PostgreSQL等效改写(保留所有city)
SELECT
user_id,
STRING_AGG(DISTINCT city, ',') as cities, -- 聚合为字符串
COUNT(*)
FROM orders
GROUP BY user_id;
-- 或使用ARRAY_AGG保留数组形式
SELECT
user_id,
ARRAY_AGG(DISTINCT city) as city_array,
COUNT(*)
FROM orders
GROUP BY user_id;方案II:使用窗口函数(推荐,性能最优)
-- 原始意图:获取每个user_id的最新city
-- MySQL(依赖隐式排序)
SELECT user_id, city, order_cnt
FROM (
SELECT user_id, city, COUNT(*) as order_cnt
FROM orders
GROUP BY user_id, city
) t
GROUP BY user_id; -- city不确定
-- PostgreSQL(显式最新值)
WITH user_stats AS (
SELECT
user_id,
city,
COUNT(*) as order_cnt,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY MAX(created_at) DESC) as rn
FROM orders
GROUP BY user_id, city
)
SELECT user_id, city, order_cnt
FROM user_stats
WHERE rn = 1; -- 明确选择最新的city方案III:创建确定性聚合函数
-- 自定义"取第一个值"的聚合函数
CREATE OR REPLACE FUNCTION first_agg(anyelement, anyelement)
RETURNS anyelement
LANGUAGE SQL
IMMUTABLE PARALLEL SAFE
AS $$ SELECT $1 $$;
CREATE AGGREGATE FIRST(anyelement) (
SFUNC = first_agg,
STYPE = anyelement,
INITCOND = ''
);
-- 使用
SELECT
user_id,
FIRST(city ORDER BY created_at) as first_city, -- 明确排序规则
COUNT(*)
FROM orders
GROUP BY user_id;方案IV:批量SQL改写工具
#!/usr/bin/env python3
# mysql_groupby_fixer.py - 自动修复GROUP BY问题
import re
import sqlparse
def fix_group_by(sql):
"""
自动将宽松的GROUP BY改写为PostgreSQL兼容形式
"""
parsed = sqlparse.parse(sql)[0]
# 提取SELECT字段
select_clause = []
group_by_clause = []
for token in parsed.tokens:
if token.ttype is sqlparse.tokens.Keyword and token.value.upper() == 'SELECT':
# 解析后续字段
for sub_token in token.parent.tokens:
if sub_token.ttype is sqlparse.tokens.Name:
select_clause.append(sub_token.value)
# 自动识别非聚合字段并添加FIRST()聚合
fixed_columns = []
for col in select_clause:
if '(' not in col: # 简单字段
fixed_columns.append(f"FIRST({col}) as {col}")
else:
fixed_columns.append(col)
# 替换SELECT部分
fixed_sql = re.sub(
r'SELECT(.*?)FROM',
f"SELECT {', '.join(fixed_columns)} FROM",
sql,
flags=re.IGNORECASE
)
return fixed_sql
# 批量处理
if __name__ == '__main__':
with open('mysql_queries.sql', 'r') as f:
for line in f:
fixed = fix_group_by(line.strip())
print(f"-- 原始: {line.strip()}")
print(f"{fixed};")
print()步骤I:建立SQL兼容性视图层
-- 在PostgreSQL中创建兼容MySQL行为的视图(临时方案)
CREATE VIEW orders_v AS
SELECT
user_id,
(ARRAY_AGG(city ORDER BY created_at DESC))[1] as city, -- 模拟ANY_VALUE
COUNT(*) as order_cnt,
MAX(amount) as max_amount
FROM orders
GROUP BY user_id;
-- 业务代码逐步从orders表迁移到orders_v
-- 最终去除视图,修改业务SQL步骤II:CI/CD集成检查
# .gitlab-ci.yml
pg_groupby_check:
stage: test
script:
- pip install sqlparse psycopg2
- python3 tests/check_groupby_strictness.py # 检查所有SQL文件
- python3 tests/fix_groupby_issues.py # 自动修复并提交PR
only:
- merge_requests
迁移后第五天,用户反馈搜索结果"全乱了"。排查发现,LIKE '%用户%'查询在PostgreSQL中返回的结果集与MySQL完全不同。更严重的是,某些用户名包含emoji的订单在模糊匹配时直接丢失。
MySQL表现:
-- MySQL 8.0,默认utf8mb4_unicode_ci
SELECT * FROM users WHERE nickname LIKE '%用户%';
-- 返回: 用户123, 用户_456, 用户😊789(共150条)
-- 大小写不敏感
SELECT * FROM users WHERE nickname = 'USER'; -- 返回 user, USER, UserPostgreSQL表现:
-- PostgreSQL 15.3,默认en_US.UTF-8
SELECT * FROM users WHERE nickname LIKE '%用户%';
-- 返回: 用户123, 用户_456(仅120条,emoji记录丢失!)
-- 大小写敏感
SELECT * FROM users WHERE nickname = 'USER'; -- 无结果!影响评估:搜索功能召回率下降15%,用户投诉激增;订单查询漏掉含emoji的用户名,导致客服无法定位订单,影响约800单/天。
MySQL和PostgreSQL的字符集/排序规则(Collation)实现有本质差异:
字符集对比表:
特性 | MySQL utf8mb4 | PostgreSQL UTF8 | 风险等级 |
|---|---|---|---|
默认排序规则 | utf8mb4_unicode_ci | en_US.UTF-8 | 高 |
大小写敏感性 | 可配置 | 依赖locale | 中 |
Emoji支持 | 完整(4字节) | 完整(UTF8) | 低 |
模糊匹配 | 基于collation | 基于字节序 | 高 |
LIKE性能 | 索引可用 | 索引可能失效 | 中 |
关键差异:
utf8mb4_unicode_ci:基于Unicode排序算法,不区分大小写,支持emoji权重en_US.UTF-8:基于C库locale,区分大小写,emoji处理不一致LIKE使用索引且遵循collation,PostgreSQL的LIKE对非ASCII字符可能退化为全表扫描方案I:全文搜索替代LIKE(推荐)
-- 步骤1:创建GIN索引
CREATE EXTENSION pg_trgm; -- 三元组匹配
CREATE EXTENSION unaccent; -- 重音符号处理
-- 步骤2:创建文本搜索配置
CREATE TEXT SEARCH CONFIGURATION cn_unaccent (COPY = pg_catalog.simple);
ALTER TEXT SEARCH CONFIGURATION cn_unaccent
ALTER MAPPING FOR hword, hword_part, word WITH unaccent, simple;
-- 步骤3:创建索引
CREATE INDEX idx_nickname_trgm ON users
USING GIN (nickname gin_trgm_ops);
-- 步骤4:改写查询
-- MySQL: WHERE nickname LIKE '%用户%'
-- PostgreSQL:
SELECT * FROM users
WHERE nickname % '用户' -- 相似度操作符
OR to_tsvector('cn_unaccent', nickname) @@ to_tsquery('用户');
-- 性能对比:
-- LIKE '%用户%': 800ms,全表扫描
-- %操作符: 15ms,GIN索引
-- @@操作符: 25ms,全文索引方案II:强制统一排序规则
-- 为所有文本字段指定COLLATE
ALTER TABLE users ALTER COLUMN nickname TYPE TEXT COLLATE "zh_CN.utf8";
-- 创建不区分大小写的索引
CREATE INDEX idx_nickname_ci ON users (nickname COLLATE "zh_CN.utf8");
-- 修改数据库默认排序规则
ALTER DATABASE algorithm_db SET LC_COLLATE = 'zh_CN.UTF-8';
ALTER DATABASE algorithm_db SET LC_CTYPE = 'zh_CN.UTF-8';
-- 注意:需重新创建数据库生效方案III:应用层一致性哈希
# Python应用层处理大小写和emoji
import re
import hashlib
def normalize_query(query: str) -> str:
"""
统一查询字符串格式:
1. 转小写
2. 去除emoji(可选,根据业务)
3. 标准化空格
"""
# 转小写
query = query.lower()
# 去除emoji(保留表情但不用于模糊匹配)
emoji_pattern = re.compile(
"[\U0001F600-\U0001F64F]|[\U0001F300-\U0001F5FF]|...",
flags=re.UNICODE
)
query_clean = emoji_pattern.sub('', query)
return query_clean.strip()
# 查询时
search_term = normalize_query('用户😊123')
# 生成两个查询:一个带emoji,一个不带
sql_with_emoji = "SELECT * FROM users WHERE nickname ILIKE %s"
sql_without = "SELECT * FROM users WHERE nickname ILIKE %s"方案IV:索引级别强制转换
-- 创建函数索引实现大小写不敏感
CREATE INDEX idx_nickname_lower ON users (LOWER(nickname));
-- 改写查询
SELECT * FROM users WHERE LOWER(nickname) = LOWER('USER'); -- 使用索引
-- 或使用CITEXT扩展
CREATE EXTENSION citext; -- 大小写不敏感文本
ALTER TABLE users ALTER COLUMN nickname TYPE CITEXT;
-- 现在可以直接使用,无需LOWER()
SELECT * FROM users WHERE nickname = 'USER'; -- CITEXT索引生效紧急修复脚本:
-- 第1步:识别所有有问题的查询
SELECT query, calls, total_exec_time
FROM pg_stat_statements
WHERE query LIKE '%LIKE%'
AND query ILIKE '%nickname%'
ORDER BY total_exec_time DESC;
-- 第2步:创建必要的扩展和索引(在线操作)
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS citext;
-- 第3步:改写Top10慢查询
-- 使用pg_hint_plan强制索引(需安装扩展)
SELECT /*+ BitmapScan(users idx_nickname_trgm) */ *
FROM users WHERE nickname LIKE '%用户%';
迁移后第七天,特征工程团队报告核心特征计算任务耗时从MySQL的45分钟增加到PostgreSQL的2.5小时,涨幅达233%。该任务大量使用窗口函数计算用户行为序列特征。
问题SQL:
-- MySQL 8.0: 45分钟完成
WITH user_sequence AS (
SELECT
user_id,
event_type,
event_time,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_time) as rn,
LAG(event_type, 1) OVER (PARTITION BY user_id ORDER BY event_time) as prev_event
FROM user_behavior
WHERE event_time BETWEEN '2024-01-01' AND '2024-01-31'
)
SELECT
user_id,
COUNT(*) as total_events,
SUM(CASE WHEN event_type = prev_event THEN 1 ELSE 0 END) as repeat_event_cnt
FROM user_sequence
GROUP BY user_id;
-- PostgreSQL 15.3: 2小时32分钟
-- 相同SQL,资源使用率低(CPU 30%,I/O 40%)性能对比表:
指标 | MySQL 8.0 | PostgreSQL 15.3 | 差异 |
|---|---|---|---|
执行时间 | 45分钟 | 152分钟 | +238% |
CPU峰值 | 85% | 35% | 资源闲置 |
内存使用 | 12GB | 8GB | 偏低 |
索引使用 | 覆盖索引 | 仅扫描索引 | 效率低 |
并行度 | 无并行 | 未启用并行 | 未利用硬件 |
PostgreSQL的窗口函数默认是串行执行的,且对内存使用非常保守。MySQL 8.0的窗口函数虽然也不并行,但优化器更倾向于使用覆盖索引避免回表。
执行计划对比:
-- MySQL EXPLAIN
/*
-> Window function: row_number() OVER (PARTITION BY user_id ORDER BY event_time)
-> Index scan on user_behavior (user_id, event_time) -- 覆盖索引
Cost: 125000 rows, 45min
*/
-- PostgreSQL EXPLAIN
/*
WindowAgg (cost=100000.00..500000.00 rows=125000 width=48)
-> Sort (cost=100000.00..102500.00 rows=1000000 width=40)
Sort Key: user_id, event_time
-> Seq Scan on user_behavior (cost=0.00..25000.00 rows=1000000 width=40)
Filter: (event_time BETWEEN '2024-01-01' AND '2024-01-31')
*/
-- 关键问题:
-- 1. 未使用索引(event_time条件在Filter而非Index Cond)
-- 2. 全表扫描后排序(Sort),内存不足时写磁盘
-- 3. 串行执行窗口函数方案I:索引优化+并行执行(推荐)
-- 步骤1:创建覆盖索引(Index Only Scan)
CREATE INDEX idx_behavior_window ON user_behavior
(user_id, event_time) INCLUDE (event_type);
-- 步骤2:强制并行执行
SET max_parallel_workers_per_gather = 8;
SET work_mem = '512MB'; -- 为排序分配更多内存
-- 步骤3:改写查询以支持并行窗口
WITH user_sequence AS (
SELECT
user_id,
event_type,
event_time,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_time) as rn,
LAG(event_type, 1) OVER (PARTITION BY user_id ORDER BY event_time) as prev_event
FROM user_behavior
WHERE event_time BETWEEN '2024-01-01' AND '2024-01-31'
)
SELECT
user_id,
COUNT(*) as total_events,
SUM(CASE WHEN event_type = prev_event THEN 1 ELSE 0 END) as repeat_event_cnt
FROM user_sequence
GROUP BY user_id;
-- 优化后执行计划:
-- WindowAgg (cost=125000.00..200000.00 rows=125000 width=48)
-- -> Gather Merge (cost=100000.00..125000.00 rows=125000 width=48)
-- Workers Planned: 8
-- -> Sort (cost=99000.00..100000.00 rows=15625 width=48)
-- Sort Key: user_id, event_time
-- -> Parallel Index Only Scan on user_behavior方案II:物化中间结果(内存不足时)
-- 当数据量超过内存时,使用临时表物化
CREATE TEMP TABLE user_sequence_temp AS
SELECT
user_id,
event_type,
event_time,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY event_time) as rn,
LAG(event_type, 1) OVER (PARTITION BY user_id ORDER BY event_time) as prev_event
FROM user_behavior
WHERE event_time BETWEEN '2024-01-01' AND '2024-01-31';
-- 在临时表上创建索引加速后续聚合
CREATE INDEX idx_temp_user ON user_sequence_temp (user_id);
-- 最终聚合
SELECT
user_id,
COUNT(*) as total_events,
SUM(CASE WHEN event_type = prev_event THEN 1 ELSE 0 END) as repeat_event_cnt
FROM user_sequence_temp
GROUP BY user_id;
-- 使用ON COMMIT DROP自动清理
CREATE TEMP TABLE user_sequence_temp (...) ON COMMIT DROP;方案III:使用plpython3u并行计算(极端场景)
-- 创建并行安全函数
CREATE OR REPLACE FUNCTION calc_user_features_parallel()
RETURNS TABLE (user_id BIGINT, repeat_cnt INT) AS $$
import pandas as pd
import numpy as np
from sklearn.feature_extraction import DictVectorizer
# 从PostgreSQL读取数据(并行执行)
query = """
SELECT user_id, event_type, event_time::TEXT
FROM user_behavior
WHERE event_time BETWEEN '2024-01-01' AND '2024-01-31'
"""
# 使用pandas原生窗口函数
df = plpy.execute(query)
df['event_time'] = pd.to_datetime(df['event_time'])
df = df.sort_values(['user_id', 'event_time'])
# 向量化计算
df['prev_event'] = df.groupby('user_id')['event_type'].shift(1)
df['is_repeat'] = (df['event_type'] == df['prev_event']).astype(int)
result = df.groupby('user_id').agg({
'is_repeat': 'sum',
'event_type': 'count'
}).reset_index()
return result.itertuples(index=False)
$$ LANGUAGE plpython3u PARALLEL SAFE;
-- 调用(自动并行)
SELECT * FROM calc_user_features_parallel();方案IV:JIT编译加速
-- 开启JIT编译,优化窗口函数表达式
ALTER SYSTEM SET jit = on;
ALTER SYSTEM SET jit_above_cost = 50000; -- 降低阈值
SELECT pg_reload_conf();
-- 重新执行查询,观察JIT效果
EXPLAIN (ANALYZE, TIMING)
SELECT ...; -- 在输出中应看到"JIT:"段落-- 创建性能对比视图
CREATE VIEW v_window_perf AS
SELECT
query,
calls,
total_exec_time,
mean_exec_time,
stddev_exec_time,
rows
FROM pg_stat_statements
WHERE query LIKE '%WINDOW%' OR query LIKE '%OVER%'
ORDER BY total_exec_time DESC;
-- 基线监控
SELECT
now(),
(SELECT total_exec_time FROM v_window_perf WHERE query LIKE '%user_behavior%') as window_time,
(SELECT total_exec_time FROM v_window_perf WHERE query LIKE '%model_predictions%') as model_time;优化效果表:
优化措施 | 执行时间 | 加速比 | CPU使用率 | 内存使用 |
|---|---|---|---|---|
原始SQL | 152分钟 | 1x | 35% | 8GB |
+覆盖索引 | 89分钟 | 1.7x | 45% | 12GB |
+并行8 workers | 34分钟 | 4.5x | 85% | 32GB |
+JIT编译 | 28分钟 | 5.4x | 90% | 35GB |
+物化临时表 | 25分钟 | 6.1x | 80% | 20GB |

迁移后第十天,监控告警磁盘空间使用率从60%暴涨至95%,每天增长约50GB。业务写入量正常,没有大量新增数据。du -sh /data/pgsql显示数据目录已达2.5TB,而实际SELECT pg_database_size()仅1.2TB。差额的1.3TB去哪了?
现象观察:
# 数据目录空间分布
$ du -sh /data/pgsql/data/*
1.2T base # 实际数据
50G pg_wal # WAL日志
1.3T pg_xact # 事务目录(异常大!)
20G pg_log
# pg_xact目录下大量活跃事务文件
$ ls -lh pg_xact/ | head
-rw------- 1 postgres postgres 256M Jan 15 10:23 0000
-rw------- 1 postgres postgres 256M Jan 15 10:24 0001
...
-rw------- 1 postgres postgres 256M Jan 15 10:45 0512 # 512个文件!影响评估:磁盘空间告警导致写入阻塞,服务可用性下降;1.3TB的"幽灵空间"造成成本虚增;紧急扩容增加运维成本5万元/月。
PostgreSQL的MVCC机制与MySQL的InnoDB有本质不同:
MVCC对比表:
特性 | MySQL InnoDB | PostgreSQL | 风险等级 |
|---|---|---|---|
旧版本存储 | Undo Log(回滚段) | 原地保留旧版本 | 中 |
清理机制 | 后台purge线程 | VACUUM进程 | 高 |
空间回收 | 自动、及时 | 依赖VACUUM频率 | 高 |
事务ID | 6字节递增 | 4字节循环(XID) | 中 |
膨胀控制 | 较好 | 需手动调优 | 高 |
关键问题:
DELETE + INSERT,旧元组标记为dead,等待VACUUM回收autovacuum_vacuum_scale_factor = 0.2,对于大表需要20%变更才触发pg_xact文件存储事务提交状态,长事务导致文件无法清理方案I:紧急VACUUM FULL(会造成锁表)
-- 步骤1:终止长事务
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'active'
AND query_start < NOW() - INTERVAL '2 hours'
AND usename = 'analyst_user';
-- 步骤2:手动VACUUM(不锁表,但慢)
VACUUM (VERBOSE, ANALYZE) user_behavior;
-- 步骤3:VACUUM FULL(锁表,彻底回收空间)
VACUUM FULL user_behavior; -- 需在维护窗口执行
-- 步骤4:清理pg_xact
CHECKPOINT; -- 强制刷盘
SELECT pg_clean_xlog(); -- 需要superuser方案II:激进式Autovacuum调优(推荐)
-- 修改postgresql.conf
ALTER SYSTEM SET autovacuum = on;
ALTER SYSTEM SET autovacuum_max_workers = 8; -- 增加worker数
ALTER SYSTEM SET autovacuum_naptime = '30s'; -- 缩短检查间隔
-- 大表专用策略
ALTER SYSTEM SET autovacuum_vacuum_scale_factor = 0.05; -- 5%变更就触发
ALTER SYSTEM SET autovacuum_vacuum_threshold = 1000; -- 至少1000条变更
-- 针对特定表(如频繁更新的特征表)
ALTER TABLE user_behavior SET (
autovacuum_vacuum_scale_factor = 0.01, -- 1%就触发
autovacuum_vacuum_threshold = 500,
fillfactor = 85 -- 预留15%空间给UPDATE,减少页分裂
);
-- 应用配置
SELECT pg_reload_conf();
-- 监控Autovacuum
CREATE VIEW v_autovacuum_stats AS
SELECT
schemaname,
relname,
last_vacuum,
last_autovacuum,
n_tup_ins,
n_tup_upd,
n_tup_del,
n_live_tup,
n_dead_tup,
ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) as dead_ratio
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY dead_ratio DESC;方案III:分区表避免膨胀
-- 将大表按时间分区,旧分区只读,无需VACUUM
CREATE TABLE user_behavior_range (
user_id BIGINT,
event_time TIMESTAMPTZ,
...
) PARTITION BY RANGE (event_time);
-- 每月一个分区,旧分区设置为只读
ALTER TABLE user_behavior_range_202312 SET (
autovacuum_enabled = false, -- 关闭旧分区VACUUM
fillfactor = 100
);
-- 使用pg_partman自动管理分区
CREATE EXTENSION pg_partman;
SELECT partman.create_parent('public.user_behavior_range', 'event_time', 'native', 'monthly');方案IV:监控告警体系
-- 创建膨胀率监控函数
CREATE OR REPLACE FUNCTION check_table_bloat()
RETURNS TABLE (
table_name TEXT,
dead_tup_count BIGINT,
dead_ratio FLOAT,
action TEXT
) AS $$
SELECT
relname,
n_dead_tup,
ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2),
CASE
WHEN n_dead_tup > 100000 AND
100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0) > 20
THEN 'URGENT: VACUUM FULL required'
WHEN n_dead_tup > 10000 THEN 'WARNING: Run VACUUM'
ELSE 'OK'
END
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000;
$$ LANGUAGE sql;
-- 定时任务(cron)
*/30 * * * * psql -d algorithm_db -c "SELECT * FROM check_table_bloat()" | mail -s "PG Bloat Alert" dba@company.com配置参数对比表:
参数名 | 默认值 | 迁移后推荐值 | 说明 |
|---|---|---|---|
| 3 | 8 | 加速清理,避免堆积 |
| 1min | 30s | 更频繁检查 |
| 0.2 | 0.05 | 大表5%变更就触发 |
| 64MB | 2GB | 加速VACUUM速度 |
| -1 | 60min | 限制长查询时间,避免阻塞 |

迁移后第十二天,基于地理位置的推荐算法突然失效。用户反馈"附近商家"功能返回的商家距离偏差在500米至2公里之间。更严重的是,某个商圈的订单统计量一夜之间下降了40%,经查是地理围栏判定逻辑出错。
问题SQL对比:
MySQL原始逻辑:
-- MySQL 8.0 + GIS扩展
SELECT
merchant_id,
ST_Distance_Sphere(
POINT(user_lon, user_lat),
POINT(merchant_lon, merchant_lat)
) / 1000 as distance_km
FROM users, merchants
WHERE ST_Contains(
ST_GeomFromText('POLYGON((116.39 39.9, 116.40 39.9, ...))'),
POINT(user_lon, user_lat)
);PostgreSQL迁移后:
-- 使用PostGIS扩展
SELECT
merchant_id,
ST_Distance(
ST_MakePoint(user_lon, user_lat)::geography,
ST_MakePoint(merchant_lon, merchant_lat)::geography
) / 1000 as distance_km
FROM users, merchants
WHERE ST_Contains(
ST_GeomFromText('POLYGON((116.39 39.9, 116.40 39.9, ...))', 4326),
ST_MakePoint(user_lon, user_lat)::geometry
);
-- 结果:距离偏差20%,商圈判定漏掉30%用户影响评估:LBS推荐准确率下降25%,GMV损失约15万元/天;商家端配送费计算错误,引发20+起投诉。
坐标系与单位差异表:
数据库 | 函数 | 坐标系 | 单位 | 默认SRID | 风险等级 |
|---|---|---|---|---|---|
MySQL |
| 球面 | 米 | 无 | 高 |
MySQL |
| 平面 | 度 | 0 | 高 |
PostGIS |
| 可配置 | 度/米 | 0/4326 | 高 |
PostGIS |
| 球面 | 米 | 无 | 中 |
关键错误:
ST_GeomFromText未指定SRID 4326(WGS84),导致坐标系混乱geometry使用平面计算(度),geography使用球面计算(米),距离相差约30%ST_Distance有多个重载版本,参数类型隐式转换可能导致意外行为方案I:统一使用geography类型(推荐)
-- 步骤1:升级数据类型
ALTER TABLE users
ALTER COLUMN location TYPE geography(POINT, 4326)
USING ST_MakePoint(lon, lat)::geography;
ALTER TABLE merchants
ALTER COLUMN location TYPE geography(POINT, 4326)
USING ST_MakePoint(lon, lat)::geography;
-- 步骤2:创建geography索引
CREATE INDEX idx_users_location_geo ON users USING GIST (location);
CREATE INDEX idx_merchants_location_geo ON merchants USING GIST (location);
-- 步骤3:改写查询为球面距离(精确)
SELECT
merchant_id,
ST_Distance(
u.location,
m.location
) / 1000 as distance_km -- 结果单位已经是米
FROM users u, merchants m
WHERE ST_DWithin(
u.location,
m.location,
5000 -- 5公里范围,利用geography索引
);
-- 步骤4:验证准确性
SELECT
ST_Distance(
ST_MakePoint(116.40, 39.90)::geography,
ST_MakePoint(116.41, 39.91)::geography
) as distance; -- 应返回约1480米(球面距离)方案II:geometry + ST_Transform(需要投影)
-- 对需要平面计算的场景(如网格统计)
-- 1. 先转换为geometry
ALTER TABLE users ADD COLUMN location_geom geometry(POINT, 4326);
UPDATE users SET location_geom = location::geometry;
-- 2. 转换到UTM投影(北京区域:UTM Zone 50N)
ALTER TABLE users ADD COLUMN location_utm geometry(POINT, 32650);
UPDATE users SET location_utm = ST_Transform(location_geom, 32650);
-- 3. 使用平面距离(更快,但需定期重新投影)
CREATE INDEX idx_users_location_utm ON users USING GIST (location_utm);
-- 4. 查询(10公里内)
SELECT
merchant_id,
ST_Distance(u.location_utm, m.location_utm) / 1000 as distance_km
FROM users u, merchants m
WHERE ST_DWithin(u.location_utm, m.location_utm, 10000);方案III:自定义UDF保持MySQL兼容
-- 创建MySQL兼容函数
CREATE OR REPLACE FUNCTION st_distance_sphere(lat1 float, lon1 float, lat2 float, lon2 float)
RETURNS float AS $$
SELECT ST_Distance(
ST_MakePoint(lon1, lat1)::geography,
ST_MakePoint(lon2, lat2)::geography
);
$$ LANGUAGE SQL IMMUTABLE PARALLEL SAFE;
-- 改写业务SQL(最小改动)
SELECT
merchant_id,
st_distance_sphere(user_lat, user_lon, merchant_lat, merchant_lon) / 1000 as distance_km
FROM users, merchants;
-- 创建函数索引(推荐)
CREATE INDEX idx_distance_sphere ON merchants
USING GIST (ST_MakePoint(merchant_lon, merchant_lat)::geography);方案IV:Polygon方向验证与修复
-- 检查Polygon有效性
SELECT
ST_IsValidReason(geofence) as validity,
ST_Area(geofence) as area
FROM business_districts;
-- 修复顶点顺序(强制逆时针)
UPDATE business_districts
SET geofence = ST_ForcePolygonCCW(geofence)
WHERE NOT ST_IsValid(geofence);
-- 确保闭合
UPDATE business_districts
SET geofence = ST_AddPoint(geofence, ST_StartPoint(geofence))
WHERE NOT ST_Equals(ST_StartPoint(geofence), ST_EndPoint(geofence));
-- 验证包含关系
SELECT
user_id,
district_name
FROM users u
JOIN business_districts d ON ST_Contains(d.geofence, u.location::geometry)
WHERE ST_IsValid(d.geofence);#!/usr/bin/env python3
# validate_gis_migration.py
import psycopg2
import math
def haversine_distance(lon1, lat1, lon2, lat2):
"""手动计算球面距离(基准)"""
R = 6371.0 # 地球半径(公里)
dlon = math.radians(lon2 - lon1)
dlat = math.radians(lat2 - lat1)
a = math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon/2)**2
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
return R * c
def test_postgis_accuracy(conn):
"""验证PostGIS距离计算准确性"""
cur = conn.cursor()
test_cases = [
(116.40, 39.90, 116.41, 39.91), # 北京附近
(121.47, 31.23, 121.48, 31.24), # 上海附近
]
for lon1, lat1, lon2, lat2 in test_cases:
# 手动计算
manual = haversine_distance(lon1, lat1, lon2, lat2)
# PostGIS计算
cur.execute("""
SELECT ST_Distance(
ST_MakePoint(%s, %s)::geography,
ST_MakePoint(%s, %s)::geography
) / 1000
""", (lon1, lat1, lon2, lat2))
postgis = cur.fetchone()[0]
# 误差容忍度:<0.1%
error = abs(manual - postgis) / manual * 100
status = "PASS" if error < 0.1 else "FAIL"
print(f"手动: {manual:.6f}km, PostGIS: {postgis:.6f}km, 误差: {error:.4f}% [{status}]")
if __name__ == '__main__':
conn = psycopg2.connect("host=pg-prod dbname=algorithm_db user=gis_user")
test_postgis_accuracy(conn)
conn.close()
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。