实时数据库踩坑记录

实时数据库一旦进项目,好看的架构图就没那么管用了。

很多人一上来就讲实时数据库的全景图;我更想先把这次卡住的点说清楚——内存顶满、查询能力跟不上。

缘起

大概三个月前,我们的实时数据系统开始频繁报警。Redis 的内存占用一路攀升到 92%,监控群里每隔几天就会收到一条"Redis 内存使用率超过阈值"的消息。

这不是个新问题。从 2024 年初上线,我们就在用 Redis 做实时数据存储——用户行为埋点、实时统计、缓存热点数据,所有这些全部塞进同一个 Redis 实例里。当时考虑也很简单:实时数据嘛,就是图个快,Redis 又是业界标准方案,不会错。

但现实打脸的速度比我预期的快。用户量上来后,数据量跟着涨,原来设定的 32GB 内存很快吃紧。我们试过常规套路:清理过期 key、压缩数据结构、调整 LRU 策略,甚至想过上 Redis Cluster。但每做一次优化,买来的时间就只有一两周,然后又是同样的问题。

这时候有人提议:要不换 TiDB?理由很诱人:TiDB 兼容 MySQL 协议,扩展容易,还有实时分析能力,能同时解决存储和查询两个问题。

听起来很美好,但真实迁移过程比想象中复杂得多。

当时的架构

先交代下改造前的架构,方便理解后面的问题。

我们的实时数据系统是一个典型的日志埋点链路:前端埋点上报到 Kafka,后端消费 Kafka 做实时计算,结果写到 Redis,业务接口从 Redis 读取。

flowchart LR A[前端埋点] -->|HTTP| B[网关层] B -->|Kafka| C[实时计算服务] C -->|SET/INCR| D[(Redis)] D -->|GET/MGET| E[业务接口] E -->|JSON| F[前端展示] style D fill:#f9d5cc

Redis 里的数据主要有三类:

  1. 用户行为计数:用 Redis 的 INCR 命令,每个用户每天一个 key,过期时间 30 天
  2. 实时统计数据:每个维度一个 sorted set,用 ZINCRBY 记录实时热度,数据量随时间线性增长
  3. 热点缓存:用 String 存 JSON,过期时间 5-30 分钟不等

Redis 的配置也很常规,单机部署,持久化用 RDB + AOF,maxmemory-policy 设为 allkeys-lru

这套架构在数据量小的时候很丝滑:QPS 到 5 万以内,延迟都在 10ms 以下。但问题出在"数据量小"这个前提不可持续——每条新增的埋点都要同时写多个 Redis key,数据膨胀速度比业务增长还快。

为什么考虑 TiDB

换成 TiDB 不是拍脑袋决定的。我们列了一个对比清单,大概是这样:

维度RedisTiDB
写入延迟<1ms1-5ms
查询延迟<1ms(简单查询)2-10ms(简单查询)
数据容量受内存限制理论上无限(取决于磁盘)
查询能力KV 操作、简单聚合SQL 查询、复杂分析
水平扩展需要 Cluster透明扩展
运维成本低(单机)中高(分布式)
数据一致性最终一致(持久化配置)强一致(默认)

我们当时的判断是:写入延迟增加几毫秒可以接受,但容量问题和查询能力的提升才是刚需。TiDB 的 SQL 查询能力意味着我们可以直接在数据库里做很多原来需要在应用层实现的聚合计算,这个收益是立竿见影的。

另外还有一个现实考虑:TiDB 用的是 MySQL 协议,应用层的改动主要是改连接池和查询方式,不像换数据库还要重新学一套 API。

迁移方案设计

迁移方案大致分三步走:

  1. 数据结构迁移:把 Redis 的数据结构映射到 TiDB 的表结构
  2. 双写阶段:应用同时写 Redis 和 TiDB,持续观察一周
  3. 读切换:先把只读流量切到 TiDB,确认无误后逐步下线 Redis

但实际执行的时候,这几步都踩了坑。

数据结构迁移

最头疼的是数据结构差异。Redis 的 INCR 对应的是原子递增,TiDB 没有直接对应的原子操作,只能用 INSERT ... ON DUPLICATE KEY UPDATE 模拟。

原始 Redis 的用户行为计数是这样的:

INCR user:behavior:12345:20240716:view  # 用户 12345 在 2024-07-16 的浏览次数
EXPIRE user:behavior:12345:20240716:view 2592000  # 30 天过期

对应的 TiDB 表结构:

CREATE TABLE user_behavior (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    behavior_date DATE NOT NULL,
    behavior_type VARCHAR(32) NOT NULL,
    count BIGINT NOT NULL DEFAULT 1,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_user_behavior (user_id, behavior_date, behavior_type),
    KEY idx_behavior_date (behavior_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

写入的时候需要这样:

INSERT INTO user_behavior (user_id, behavior_date, behavior_type, count)
VALUES (12345, '2024-07-16', 'view', 1)
ON DUPLICATE KEY UPDATE count = count + 1, updated_at = CURRENT_TIMESTAMP;

这看起来没问题,但批量写入的时候性能就暴露出来了。原来 Redis 支持管道操作,一条连接可以一次性发送多条 INCR 命令,TiDB 只能一条条 SQL 插入。

我们的解决方式是攒批量:在应用层收集同一批次的数据,转换成一条多值插入:

INSERT INTO user_behavior (user_id, behavior_date, behavior_type, count)
VALUES
    (12345, '2024-07-16', 'view', 1),
    (12345, '2024-07-16', 'click', 1),
    (67890, '2024-07-16', 'view', 2)
ON DUPLICATE KEY UPDATE
    count = VALUES(count) + count,
    updated_at = CURRENT_TIMESTAMP;

这个改动让批量写入的吞吐量提升了大概 3 倍,但 Redis 的管道操作还是更快。

过期策略

Redis 的过期处理是自动的,TiDB 需要自己实现。我们试过几个方案:

  1. 定时清理任务:每天凌晨扫一遍表,删除超过 30 天的数据
  2. 分区表:按 behavior_date 做分区,定期 DROP PARTITION
  3. 应用层判断:查询时先检查日期,超过范围的不读

最终选了分区表,因为运维成本最低:

ALTER TABLE user_behavior
PARTITION BY RANGE (TO_DAYS(behavior_date)) (
    PARTITION p20240701 VALUES LESS THAN (TO_DAYS('2024-07-02')),
    PARTITION p20240702 VALUES LESS THAN (TO_DAYS('2024-07-03')),
    -- ... 每天一个分区
    PARTITION p20240801 VALUES LESS THAN (TO_DAYS('2024-08-02')),
    PARTITION p_future VALUES LESS THAN MAXVALUE
);

清理的时候只需要 ALTER TABLE user_behavior DROP PARTITION p20240701,比扫表删数据快得多。

但这也带来了新问题:需要定期维护分区,否则分区数会无限增长。我们写了一个简单的脚本,每天凌晨自动删除 31 天前的分区:

#!/bin/bash
# 脚本路径:/opt/scripts/cleanup_partitions.sh

TIDB_HOST="tidb-server"
TIDB_PORT="4000"
DATABASE="realtime_db"
TABLE="user_behavior"
RETENTION_DAYS=31

# 计算删除的分区日期
DELETE_DATE=$(date -d "$RETENTION_DAYS days ago" +%Y%m%d)
PARTITION_NAME="p${DELETE_DATE}"

# 执行删除
mysql -h $TIDB_HOST -P $TIDB_PORT -u app_user -p$DB_PASSWORD $DATABASE <<EOF
ALTER TABLE $TABLE DROP PARTITION IF EXISTS $PARTITION_NAME;
EOF

把脚本加到 crontab 里每天执行一次,算是半自动化解决了过期策略问题。

双写阶段的坑

双写阶段原计划是一周,实际拖了两周。主要问题是数据不一致。

事务问题

Redis 是单线程的,天然保证原子性;TiDB 是分布式数据库,跨表事务需要小心。

我们遇到的一个典型场景:一个用户的多个行为数据需要在同一个事务里更新。Redis 里写几条 INCR 命令就行,TiDB 里必须开事务:

// 伪代码
tx, err := tidb.Begin()
if err != nil {
    return err
}

_, err = tx.Exec(`
    INSERT INTO user_behavior (user_id, behavior_date, behavior_type, count)
    VALUES (?, ?, ?, 1)
    ON DUPLICATE KEY UPDATE count = count + 1`, userID, today, "view")
if err != nil {
    tx.Rollback()
    return err
}

_, err = tx.Exec(`
    INSERT INTO user_behavior (user_id, behavior_date, behavior_type, count)
    VALUES (?, ?, ?, 1)
    ON DUPLICATE KEY UPDATE count = count + 1`, userID, today, "click")
if err != nil {
    tx.Rollback()
    return err
}

err = tx.Commit()
if err != nil {
    return err
}

但实际运行中发现,事务超时是个常见问题。TiDB 默认事务超时是 50 秒,而我们的批量写入有时候会超过这个限制。

调整后改成了短事务模式:把一个批量任务拆分成多个小批次,每批不超过 1000 条记录,单个事务尽量控制在 10 秒以内完成。

// 伪代码:分批提交
batchSize := 1000
for i := 0; i < len(records); i += batchSize {
    end := i + batchSize
    if end > len(records) {
        end = len(records)
    }

    tx, err := tidb.Begin()
    if err != nil {
        return err
    }

    for j := i; j < end; j++ {
        _, err = tx.Exec(`INSERT ...`, records[j])
        if err != nil {
            tx.Rollback()
            return err
        }
    }

    if err = tx.Commit(); err != nil {
        return err
    }
}

这个改动后,事务超时的问题基本消失了,但应用层的复杂度明显增加。

延迟问题

双写阶段最直观的感受是延迟增加。原来 Redis 的写入延迟在 1ms 以内,TiDB 稳定在 2-5ms 之间。

这不是 TiDB 的问题,而是网络和数据结构差异导致的。我们做了几个优化:

  1. 连接池调优:把连接池大小从 10 调到 50,空闲连接保持时间延长到 5 分钟
  2. 批量写入:把原来单条写入改成批量插入,每批 100-500 条
  3. 参数调优:把 TiDB 的 max_execution_time 调整到 10 秒,避免慢查询被主动终止

最终 TiDB 的写入延迟稳定在 3ms 左右,平均比 Redis 慢 2ms,但在业务可接受范围内。

读切换

双写运行两周后,数据一致性基本验证通过,开始准备读切换。

切换策略是:先切流量,再切代码。具体做法是:

  1. 在网关层配置 10% 的只读流量到 TiDB,监控错误率和延迟
  2. 逐步提升到 30%、50%、70%、100%
  3. 全量流量在 TiDB 稳定运行 3 天后,开始下线 Redis

但实际切换过程中发现了几个问题:

查询语法差异

最典型的是聚合查询。Redis 的 sorted set 可以用 ZREVRANGE 直接获取 Top N,TiDB 需要写完整的 SQL。

原来 Redis 的查询:

ZREVRANGE trending:20240716 0 9 WITHSCORES

对应的 TiDB 查询:

SELECT item_id, score
FROM realtime_stats
WHERE stat_date = '2024-07-16'
  AND stat_type = 'trending'
ORDER BY score DESC
LIMIT 10;

这看起来还好,但复杂一点的查询就麻烦了。比如要查询某个用户在多个维度的统计汇总,Redis 里可以用多个 MGET 一次性取回,TiDB 需要写 OR 查询或者子查询。

我们针对高频查询做了专门优化,创建了一些物化视图或者临时表,但这增加了系统复杂度。

缓存穿透

Redis 有个好处:即使 key 不存在,也能快速返回;TiDB 如果查询条件不命中索引,会触发全表扫描。

我们遇到过一次生产事故:一个错误的查询条件(忘记加 stat_date 过滤)触发了全表扫描,导致 TiDB 的 CPU 飙升到 90% 以上。

解决方式是在应用层做查询拦截,强制要求必须包含日期范围;同时在 TiDB 上加了查询超时限制和慢查询告警。

迁移后的结果

整个迁移花了一个半月,比预期长。但迁移完成后,效果还算明显:

性能对比

指标RedisTiDB变化
写入 QPS50,00030,000-40%
写入延迟 (p95)2ms5ms+150%
查询 QPS100,00080,000-20%
查询延迟 (p95)1ms8ms+700%
数据容量32GB2TB+可扩展

下图把迁移前后的 QPS 和延迟放在同一坐标系里,方便看清「性能换容量」这笔账到底怎么算。

Redis 与 TiDB 迁移后写入/查询 QPS 及 p95 延迟对比

写入和查询的绝对性能确实下降了,但 2TB+ 的容量扩展才是这次迁移的核心收益——我们是用可接受的延迟代价,换掉了内存天花板。现在的数据量是迁移前的 10 倍,TiDB 还可以水平扩展,短期内不会再有容量焦虑。

运维成本

运维成本明显增加。TiDB 是分布式系统,组件多、配置复杂,需要专业的 DBA 支持。

我们组建了一个 3 人的 TiDB 运维小组,主要负责:

  1. 集群健康监控:PD、TiDB、TiKV 的健康状态,Region 均衡情况
  2. 备份恢复:定期全量备份 + 增量备份,验证恢复流程
  3. 性能调优:慢查询优化、索引管理、资源配额调整
  4. 版本升级:TiDB 版本迭代很快,需要规划升级路径

相比之下,Redis 的运维基本不需要专门的人手,一个运维同事兼职就能搞定。

成本收益

从成本角度看,这次迁移不划算:TiDB 集群的硬件成本是 Redis 的 3 倍,人力成本也增加了。

但收益也很明显:

  1. 容量扩展能力:不再有内存限制,可以放心接入更多数据源
  2. 查询能力增强:可以直接在数据库里做复杂查询,应用层代码简化
  3. 数据一致性:TiDB 的强一致性保证让数据更可靠
  4. 团队技能提升:团队成员熟悉了分布式数据库,对后续系统设计有帮助

这些收益很难量化,但对我们的业务发展很重要。

回过头看

如果再让我选一次,我还是会从 Redis 迁移到 TiDB,但我会换一种方式:

  1. 分阶段迁移:先迁非实时数据,跑稳了再动实时链路,别一口气全量切
  2. 提前做容量规划:TiDB 的部署规模要留足冗余,避免后期频繁扩容
  3. 强化监控:TiDB 的监控要比 Redis 更细致,预警阈值要更严格
  4. 团队培训:迁移前先培训团队 TiDB 的基本概念和常见问题,减少踩坑

技术选型没有银弹。Redis 快、省内存,TiDB 扛容量、跑 SQL——我们当时容量和查询能力是刚需,多几毫秒延迟能接受。延迟极度敏感、数据量还没顶到天花板,Redis 仍然更合适。

这轮迁移最大的收获,是对"选型得贴着业务瓶颈走"体会更深。没有绝对好的技术,只有当时合用的那一款。


迁移完成两个月后,我们的实时数据系统运行稳定,数据量还在涨,但团队已经不再焦虑。TiDB 的容量和扩展能力给了我们底气去接更多的业务场景,这种安心感是成本计算换不来的。

只是偶尔深夜看到监控面板上 TiDB 的 CPU 波动,会想起 Redis 那个简单直白的绿色曲线——有时候,简单本身就是一种奢侈。

可用性说明:本文发布于 2021 年 4 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。

版权声明: 本文首发于 指尖魔法屋-实时数据库踩坑记录https://blog.thinkmoon.cn/post/111-realtime-database-redis-tidb-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!