单表五亿数据的查询优化 | Mysql、StarRocks

0 阅读15分钟

机器性能

本次测试所用服务器硬件配置如下,此机器除了 Mysql 和 StartRocks 还部署了其它很多 Docker 服务。

CPU:

AMD Ryzen™ 7 8745H w/ Radeon™ 780M Graphics × 16

内存:

DDR5 5600 MT/S 32G(16G*2)

磁盘性能:

 Timing cached reads:   64174 MB in  1.99 seconds = 32256.62 MB/sec
 Timing buffered disk reads: 3562 MB in  3.00 seconds = 1186.39 MB/sec

初始化数据库环境

Mysql:

CREATE TABLE `users` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `username` varchar(32) NOT NULL,
  `phone` char(11) NOT NULL,
  `email` varchar(64) NOT NULL,
  `gender` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '0未知 1男 2女',
  `age` tinyint(3) unsigned NOT NULL DEFAULT 0,
  `status` tinyint(3) unsigned NOT NULL DEFAULT 1 COMMENT '1正常 2禁用 3注销',
  `province_id` smallint(5) unsigned NOT NULL DEFAULT 0,
  `city_id` mediumint(8) unsigned NOT NULL DEFAULT 0,
  `register_source` tinyint(3) unsigned NOT NULL DEFAULT 1 COMMENT '1web 2ios 3android 4api',
  `score` int(10) unsigned NOT NULL DEFAULT 0,
  `created_at` datetime NOT NULL,
  `updated_at` datetime NOT NULL,
  `last_login_at` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_phone` (`phone`),
  KEY `idx_status_created_id` (`status`,`created_at`,`id`),
  KEY `idx_created_at_id` (`created_at`,`id`),
  KEY `idx_email` (`email`),
  KEY `users_created_at_index` (`created_at`)
) ENGINE=InnoDB AUTO_INCREMENT=935300001 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC

StarRocks:

CREATE TABLE `users` (
  `id` bigint NOT NULL COMMENT "",
  `created_at` datetime NOT NULL COMMENT "",
  `username` varchar(32) NOT NULL COMMENT "",
  `phone` varchar(11) NOT NULL COMMENT "",
  `email` varchar(64) NOT NULL COMMENT "",
  `gender` tinyint NOT NULL DEFAULT "0" COMMENT "",
  `age` tinyint NOT NULL DEFAULT "0" COMMENT "",
  `status` tinyint NOT NULL DEFAULT "1" COMMENT "",
  `province_id` smallint NOT NULL DEFAULT "0" COMMENT "",
  `city_id` int NOT NULL DEFAULT "0" COMMENT "",
  `register_source` tinyint NOT NULL DEFAULT "1" COMMENT "",
  `score` int NOT NULL DEFAULT "0" COMMENT "",
  `updated_at` datetime NOT NULL COMMENT "",
  `last_login_at` datetime NULL COMMENT ""
)
ENGINE=OLAP 
PRIMARY KEY(`id`, `created_at`)
PARTITION BY RANGE(`created_at`) (
  START ("2020-01-01") END ("2026-12-31") EVERY (INTERVAL 1 MONTH)
)
DISTRIBUTED BY HASH(`id`) BUCKETS 8 
PROPERTIES (
"replication_num" = "1"
);

Python 写入数据的脚本:

from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta
import pymysql

HOST = "192.168.1.1"
PORT = 3306   // 或 9030
USER = "root"
PASSWORD = "123456"
DATABASE = "testdata"

START_ID = 1
TOTAL_ROWS = 500_000_000
WORKERS = 8
BATCH_SIZE = 10_000

BASE_TIME = datetime(2024, 1, 1, 0, 0, 0)

INSERT_SQL = """
INSERT INTO users (
    id, username, phone, email, gender, age, status,
    province_id, city_id, register_source, score,
    created_at, updated_at, last_login_at
) VALUES (
    %s, %s, %s, %s, %s, %s, %s,
    %s, %s, %s, %s,
    %s, %s, %s
)
"""

def make_conn():
    return pymysql.connect(
        host=HOST,
        port=PORT,
        user=USER,
        password=PASSWORD,
        database=DATABASE,
        charset="utf8mb4",
        autocommit=False,
        read_timeout=300,
        write_timeout=300,
        connect_timeout=30,
    )

def build_rows(start_id: int, end_id: int):
    rows = []
    for n in range(start_id, end_id):
        created_at = BASE_TIME + timedelta(seconds=n % 31_536_000)
        updated_at = created_at
        last_login_at = created_at + timedelta(days=n % 30)

        rows.append((
            n,
            f"user_{n}",
            f"1{n:010d}",
            f"user_{n}@test.local",
            n % 3,
            18 + (n % 43),
            2 if n % 20 == 0 else 1,
            (n % 34) + 1,
            (n % 340) + 1,
            (n % 4) + 1,
            n % 100000,
            created_at.strftime("%Y-%m-%d %H:%M:%S"),
            updated_at.strftime("%Y-%m-%d %H:%M:%S"),
            last_login_at.strftime("%Y-%m-%d %H:%M:%S"),
        ))
    return rows

def worker(worker_no: int, start_id: int, end_id: int):
    conn = make_conn()
    inserted = 0
    try:
        with conn.cursor() as cur:
            current = start_id
            while current <= end_id:
                next_id = min(current + BATCH_SIZE, end_id + 1)
                rows = build_rows(current, next_id)
                cur.executemany(INSERT_SQL, rows)
                conn.commit()

                inserted += len(rows)
                current = next_id

                if inserted % 100000 == 0 or current > end_id:
                    print(f"worker={worker_no} inserted={inserted} range={start_id}-{end_id}")
    finally:
        conn.close()

def split_ranges(start_id: int, total_rows: int, workers: int):
    base = total_rows // workers
    remain = total_rows % workers
    current = start_id
    result = []

    for i in range(workers):
        size = base + (1 if i < remain else 0)
        s = current
        e = current + size - 1
        result.append((i + 1, s, e))
        current = e + 1

    return result

def main():
    ranges = split_ranges(START_ID, TOTAL_ROWS, WORKERS)
    print("ranges:", ranges)

    with ThreadPoolExecutor(max_workers=WORKERS) as pool:
        futures = [pool.submit(worker, worker_no, s, e) for worker_no, s, e in ranges]
        for future in as_completed(futures):
            future.result()

    print("done")

if __name__ == "__main__":
    main()

5亿条数据到底占多大空间

本次测试中,MySQL实际写入数据量为4.6亿条(因测试过程中未完成5亿条写入),StarRocks按计划写入5亿条数据,以下为两者的存储占用详情。

对于 Mysql:

8.0K	./testdata/users.frm
136G	./testdata/users.ibd
4.0K	./testdata/db.opt
136G	./testdata

StarRocks 是 256G。

Mysql 表里面创建了比较多的索引,通过以下 SQL 可以获取表的索引以及索引数据占用的空间:

SELECT
    TABLE_NAME AS 表名,
    CONCAT(ROUND((INDEX_LENGTH / 1024 / 1024), 2), ' MB') AS 索引大小,
    CONCAT(ROUND((DATA_LENGTH / 1024 / 1024), 2), ' MB') AS 数据大小,
    CONCAT(ROUND(((INDEX_LENGTH + DATA_LENGTH) / 1024 / 1024), 2), ' MB') AS 总大小
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'testdata'
  AND TABLE_NAME = 'users';

image-20260412081003347

笔者这台机器部署了很多服务,所以实际上日常运行 Mysql、StarRocks 两个数据库应该不需要 10G 内存。

image-20260412082834518

统计数据量

大数据量下,全表数据量统计(select count(*))是业务中常见场景,以下为MySQL与StarRocks的性能对比,每组测试重复3次,取平均值以减少偶然误差。

select count(*) from users;

在 Mysql 里面,统计数据量是大坑,需要 1-2 分钟。

[2026-04-11 14:21:22]1 m 11 s 165 ms (execution: 1 m 11 s 153 ms, fetching: 12 ms) 内检索到从 1 开始的 1

StarRocks 只需要 260ms。

[2026-04-11 14:20:29]261 ms (execution: 254 ms, fetching: 7 ms) 内检索到从 1 开始的 1

所以对在业务系统中统计数据量是非常麻烦的一个事情,如果只是需要知道全表数据量,有很多方法可以做,例如单独设计数据量统计表、Redis 记录数据量等,但是往往页面显示数据量时需要分页、搜索、筛选,会导致在大数据量下耗时非常长。

因此在大数量时读写分离很有必要,统计数据量、分页查询大小、join 条件等通过 StarRocks 来操作。

有索引会多块

数据库本身已有以下索引:

image-20260412085856116

一开始 AI 给我生成表的时候,我在想为什么有的索引只包含字段,有的列把 id 也包进去了,查了资料发现 InnoDB 二级索引默认含主键,所以实际上 (created_at,id) 跟 (created_at) 是一样的,但是在排序方面有区别,因为索引的数据是会排序的。

以下场景显式定义 (col, id) 有明显收益:

  • 查询包含 ORDER BY col, id
  • 查询需要按 col 分页(如 WHERE col > x ORDER BY col, id LIMIT n),id 可用于稳定分页顺序;
  • col 的区分度很低(如大量重复值),加上 id 可提高索引的 “区分度”,优化索引查找效率。

回归正题,在使用主键的情况下,Mysql 读取 1000 条数据:

select *
from users
where id in (...);
[2026-04-12 08:52:56]113 ms (execution: 79 ms, fetching: 34 ms) 内检索到从 1 开始的 64

所以在表数据量非常大时,完全可以在 StarRocks 执行一些查询操作,最终获取一份数据 id 后在 Mysql 业务数据库查询数据做业务处理。

对于手机号这种字符串字段,如果做了索引,其实各种查询操作也不会慢的。

select *
from users
where phone like '1000%'
order by phone desc
limit 100 offset 10;

select *
from users
where phone like '%1000%'
order by phone desc
limit 100 offset 10;

Mysql:

[2026-04-11 14:23:05]48 ms (execution: 13 ms, fetching: 35 ms) 内检索到从 1 开始的 100[2026-04-12 09:11:44]375 ms (execution: 347 ms, fetching: 28 ms) 内检索到从 1 开始的 100

StarRocks:

[2026-04-12 09:08:09]576 ms (execution: 546 ms, fetching: 30 ms) 内检索到从 1 开始的 100[2026-04-12 09:11:36]822 ms (execution: 789 ms, fetching: 33 ms) 内检索到从 1 开始的 100

上面的测验可以说明几个问题。

对于字符串,走前缀区配时, like 'xxx%' 性能性能也会非常好,4.6 亿数据执行时间只需要 13ms。

只有前缀匹配 like 'xxx%',才能真正利用索引做范围扫描(range)B + 树可以直接定位到前缀匹配的起始位置,只扫描符合范围的索引节点,所以 %xxx% 这种走不了索引会导致全盘扫描,导致 MySQL 执行耗时从 13ms 暴涨到 347ms,性能下降了 26 倍。

不过对于 StarRocks,StarRocks 是 OLAP 引擎,默认的前缀索引对like '%x%'完全无效,所以查询都会比 Mysql 慢。

对于字符串等场景,如果设计的查询方案可以走索引,那么即使数据量很大,其实也不需要担心查询时间。

优化筛选查询

单是用户表,在业务需求中往往需要对手机号、用户名、邮箱等进行模糊查询,like %xxx% 这种情况必然会出现,我们不可能让产品经理改需求,但是无论在 Mysql 还是 StarRocks 使用 like %xxx% 在大数据量时耗时都会变大,所以我们需要找到一种方式,既可以满足产品对于订单、用户表等多个动态字段模糊搜索,又要让查询速度变快。

select *
from users
where phone like '%1000%' or email like '%user_10%' or username like '%user_11%'
order by phone desc
limit 100 offset 100;

Mysql:

[2026-04-12 09:31:37]912 ms (execution: 880 ms, fetching: 32 ms) 内检索到从 1 开始的 100

StarRocks:

[2026-04-12 09:32:24]1 s 588 ms (execution: 1 s 557 ms, fetching: 31 ms) 内检索到从 1 开始的 100

StarRocks/Doris 支持 ngram 分词倒排索引,原理和 ES 类似,但直接集成在数仓引擎中,避免了数据同步的麻烦,适合分析场景。但是经过笔者测试,where phone like '%1000%' or email like '%user_10%' or username like '%user_11%' 走不了索引,查询速度也好慢。

无论是 Mysql 还是 StarRocks 在多条件模糊查询时,由于索引机制,都会导致查询速度缓慢,最后只能使用 ElasticSearch 做模糊查询,ElasticSearch 这方面非常强。

POST /users/_search
{
  "from": 0,
  "size": 10,
  "query": {
    "bool": {
      "should": [
        { "match": { "phone": "1000" }},
        { "match": { "email": "user_10" }},
        { "match": { "username": "user_11" }}
      ],
      "minimum_should_match": 1
    }
  }
}

image-20260412173325365image-20260412160917014

一个查询只能使用一个索引

在 Mysql 的 users 表中,我们给很多字段设置了索引,包括创建时间 idx_created_at_id

PRIMARY KEY (`id`),
UNIQUE KEY `uk_phone` (`phone`),
KEY `idx_status_created_id` (`status`,`created_at`,`id`),
KEY `idx_created_at_id` (`created_at`,`id`),
KEY `idx_email` (`email`)

如果我们按时间来排查范围,有 idx_created_at_id 的加持,下面的 SQL 执行速度会不会非常快?

select *
from users
where phone like '1000%'
  and created_at > '2024-04-25 17:46:20'
order by phone desc
limit 100 offset 10;

然而实际测试,Mysql:

[2026-04-11 11:55:19]24 s 86 ms (execution: 24 s 66 ms, fetching: 20 ms) 内检索到从 1 开始的 9

StarRocks:

19 s 578 ms (execution: 19 s 561 ms, fetching: 17 ms) 内检索到 0

但是我不是给 phone、create_at 都创建索引了嘛,为什么还会这么慢?先看执行计划。

image-20260412105528953

确实有两个索引:

  1. uk_phone (phone)
  2. idx_created_at_id (created_at, id)

但问题是 MySQL 通常只能选一个 “最有用” 的索引,然后回表过滤其他条件。所以在前面的查询中,MySQL 必须二选一

  • 若选择uk_phone(实际执行计划):可快速定位phone LIKE '1000%'的2000多万行数据,但该索引不包含created_at字段,需逐行回表校验created_at条件,回表次数过多导致性能下降;
  • 若选择idx_created_at_id:可快速定位created_at > '2024-04-25'的2.3亿行数据,但该索引不包含phone字段,需逐行回表校验phone条件,扫描行数更多,性能更差;

所以对于 and 条件来说,即使每个列都添加了索引,但是一个查询里面只能使用一个索引。

对于 or 条件,倒是宽松一些,可能会走索引合并,但是大部分情况跟 and 差不多。

另外,索引的列位置也非常重要,下面举例说明。

基于 where phone like '1000%' and created_at > '2024-04-25 17:46:20',我们可以创建一个联合索引,把两个字段放在一个索引。

CREATE INDEX idx_phone_created ON users (phone, created_at);

执行这个命令重建索引花费了 17 分钟。

但是执行这个 SQL ,执行查询还是需要 20s,还是那个索引导致右侧 create_at 失效。

SELECT *
FROM users FORCE INDEX (idx_phone_created)
WHERE phone LIKE '1000%'
  AND created_at > '2024-04-25 17:46:20'
ORDER BY phone DESC
LIMIT 100 OFFSET 10;

但是如果你这样创建索引,最终需要 238030341 行扫描。

-- 新建索引:(created_at, phone)
CREATE INDEX idx_created_phone ON users (created_at, phone);

因为 LIKE '1000%' 只需要扫两千多万行,而 created_at > '2024-04-25 17:46:20' 满足的情况实在太多了,需要扫描 2.3 亿行,会导致查询时间需要几分钟!

所以做 where 查询时,需要将数据量少的限制条件放在前面,并且这个可以走索引,后面的条件可能走不了索引,会导致查询速度变慢。

并且 where 时最好不要使用 like %xxx 这种条件,否则联合索引失效!

使用 StarRocks BitMap 优化选项筛选

users 表有以下字段:

gender(0/1/2 → 仅 3 种)

status(1/2/3 → 仅 3 种)

register_source(1/2/3/4 → 仅 4 种)

province_id(省级 ID,全国≈34 → 低基数)

city_id(市级 ID,几百~几千 → 标准低基数)

在业务项目里面,表里面往往会有枚举表示数据的状态,但是这些数据在 Mysql 里面做索引会非常吃亏,因为基数低,重复率巨高,而 Mysql 默认索引是 B-tree

B-tree 索引适合高基数的数据如 user_id/phone/订单号、范围查询和数据排序,碰到这种只有集中可能的数据,使用索引会非常吃亏。

例如按照地区和注册信息条件筛选用户时:

SELECT COUNT(*) FROM users
WHERE
  status = 1
  AND gender = 1
  AND register_source = 3
  AND province_id = 11;

这个语句查询了 4 分钟还没有查完。

在 StarRocks 里面添加 BitMap 索引。

-- 给 users 表创建 Bitmap 索引
ALTER TABLE users ADD INDEX idx_bitmap_gender (gender) USING BITMAP;
ALTER TABLE users ADD INDEX idx_bitmap_status (status) USING BITMAP;
ALTER TABLE users ADD INDEX idx_bitmap_register_source (register_source) USING BITMAP;
ALTER TABLE users ADD INDEX idx_bitmap_province_id (province_id) USING BITMAP;
ALTER TABLE users ADD INDEX idx_bitmap_city_id (city_id) USING BITMAP;

执行查询:

[2026-04-12 12:56:58]116 ms (execution: 92 ms, fetching: 24 ms) 内检索到从 1 开始的 1

image-20260412125644501

大数据分页做法

在业务项目中往往需要对数据分页返回给用户,前端传递 pageNo、pageSize,但是在数据量大时,使用 where 之后再做 offset、limit 会吃掉很多性能。随着数据量达到百万、千万级别,传统 offset 分页会因为需要扫描并跳过大量数据而急剧变慢,甚至导致数据库压力过高。

为此,我们采用Token 游标分页方案,完全抛弃传统的页码与偏移量模式,只通过连续游标实现高性能滚动分页。

Token 游标分页不支持任意页码跳转,只能顺序翻页。

分页方式

第一次请求:允许前端传入 pageNo 和 pageSize,用于自定义跳转到任意起始页。

后端根据 pageNo 和 pageSize 计算出起始位置对应的最后一条数据 ID,并生成第一个 Token。

后续翻页:只需要传递上一页返回的 Token,不再需要 pageNooffset,保持高性能。

全程依然使用 where id > ? 高性能查询,兼顾跳页需求大数据性能

自定义起始页(第一次请求)

  • 前端传:pageNo=100pageSize=20(想直接从第 100 页开始)
  • 后端计算:offset = (pageNo - 1) * pageSize
  • 后端查询:order by id limit offset, pageSize
  • 拿到当前页数据 + 最后一条数据的 id
  • 生成 token,把这个 id 存入 Redis
  • 返回:数据 + nextPageToken

后续所有页(只传 token)

  • 前端只传:pageToken=xxx
  • 后端从 Redis 取出游标 id
  • 查询:where id > 游标id order by id limit pageSize
  • 生成新 token、返回新数据 + 新 token
  • 不再需要任何 pageNo、offset

C# 代码示例:

/// <summary>
/// 分页请求(第一次可传PageNo自定义起始页,后续只传Token)
/// </summary>
public class PageRequest
{
    /// <summary>
    /// 分页Token(第一次不传,传PageNo;后续只传这个)
    /// </summary>
    public string? PageToken { get; set; }

    /// <summary>
    /// 页码(仅第一次请求允许传,用于自定义起始页)
    /// </summary>
    public int PageNo { get; set; } = 1;

    /// <summary>
    /// 每页条数(10-100)
    /// </summary>
    public int PageSize { get; set; } = 20;
}


// ======================
// 情况1:传了 Token → 走游标分页
// ======================
if (!string.IsNullOrEmpty(request.PageToken))
{
	startId = RedisPageHelper.GetPageCursor(userId, request.PageToken);
}
// ======================
// 情况2:没传Token,但传了PageNo → 自定义起始页
// ======================
else
{
	int offset = (request.PageNo - 1) * request.PageSize;

	// 先查该页最后一条数据的ID
	var lastId = await _dbContext.Users
		.AsNoTracking()
		.OrderBy(u => u.Id)
		.Skip(offset)
		.Take(request.PageSize)
		.Select(u => u.Id)
		.LastOrDefaultAsync();

	startId = lastId;
}

// ======================
// 统一高性能查询
// ======================
var query = _dbContext.Users.AsNoTracking().OrderBy(u => u.Id);

if (startId.HasValue && startId.Value > 0)
{
	query = query.Where(u => u.Id > startId.Value);
}

// 多查一条判断是否有下一页
var dataList = await query.Take(request.PageSize + 1).ToListAsync();
bool hasNext = dataList.Count > request.PageSize;
if (hasNext) dataList.RemoveAt(request.PageSize);

// 生成下一页Token
string? nextToken = null;
if (hasNext && dataList.Any())
{
	ulong lastDataId = dataList.Last().Id;
	nextToken = RedisPageHelper.SetPageToken(userId, lastDataId);
}

文章转载自: 痴者工良

原文链接: www.cnblogs.com/whuanle/p/1…

体验地址: www.jnpfsoft.com/?from=413