3、数据库选型与设计:MySQL vs Redis vs 其他,表结构设计要点

抢单系统,说白了就是一场「谁手快」的游戏。

但手快的前提是什么?是数据能及时读到、能快速写入、能准确扣减。数据库选错了,你代码写得再漂亮,也是白搭。

我这些年见过太多团队,上来就 MySQL 一把梭,结果压测一上,数据库连接池直接被打满,慢查询把磁盘 IO 撑爆。也见过有人迷信 Redis,把所有数据都往内存里塞,结果一断电,订单数据全丢了,老板差点让他也「断电」。

所以这一章,咱们就聊聊抢单系统的数据库选型与表结构设计。我会结合我踩过的坑,给你一套可落地的方案。

3.1 核心矛盾:关系型 vs 缓存型

抢单系统的数据流,其实可以拆成两层:

  • 热数据层:正在抢的订单、库存余量、用户抢单状态。这些数据需要极速读写,毫秒级响应。
  • 冷数据层:历史订单、用户档案、财务流水。这些数据需要强一致性和持久化,允许秒级响应。

你想想看,如果把热数据和冷数据都塞进 MySQL,那 MySQL 既要处理高频的库存扣减,又要处理复杂的订单查询,不崩才怪。

我个人习惯的做法是:Redis 扛热数据,MySQL 做最终持久化。两者分工明确,各司其职。

核心原则:

  • Redis:负责库存扣减、抢单令牌、用户限流。数据可以短暂不一致,但必须快。
  • MySQL:负责订单落地、财务对账、历史查询。数据必须绝对准确,可以慢一点。

3.2 MySQL 表结构设计要点

很多新手设计表时,喜欢把所有字段塞进一张大表,觉得这样查询方便。嗯,这里要注意——抢单系统的订单表,是典型的「写多读少」场景,而且写入并发极高。

我曾经接手过一个项目,订单表里放了 30 多个字段,还包括一个 text 类型的备注字段。结果每次插入都要写一大坨数据,磁盘 IO 直接成了瓶颈。

3.2.1 订单主表:轻量化设计

订单主表只存核心字段,那些不常用的信息(比如收货地址、发票信息)拆到扩展表里去。

CREATE TABLE `t_order` (
  `order_id` bigint(20) NOT NULL COMMENT '订单ID(雪花算法生成)',
  `user_id` bigint(20) NOT NULL COMMENT '用户ID',
  `product_id` bigint(20) NOT NULL COMMENT '商品ID',
  `sku_id` bigint(20) DEFAULT NULL COMMENT 'SKU ID',
  `quantity` int(11) NOT NULL DEFAULT '1' COMMENT '数量',
  `total_amount` decimal(10,2) NOT NULL COMMENT '总金额',
  `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '订单状态:0-待支付 1-已支付 2-已取消',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`order_id`),
  KEY `idx_user_id` (`user_id`),
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';

注意几个细节:

  • 主键用雪花算法,别用自增 ID。抢单系统分库分表是常态,自增 ID 在分布式环境下会打架。
  • 索引不要贪多。我见过有人给订单表建了七八个索引,结果写入时索引维护成本比数据写入还高。只给 user_id 和 create_time 建索引就够了,其他查询走 ES 或 ClickHouse。
  • status 用 tinyint,别用 varchar。省空间,查询也快。

3.2.2 库存扣减表:防超卖的关键

库存扣减是抢单系统的核心。我的做法是单独建一张库存流水表,记录每一次扣减操作。

CREATE TABLE `t_stock_deduction` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增ID',
  `product_id` bigint(20) NOT NULL COMMENT '商品ID',
  `sku_id` bigint(20) DEFAULT NULL COMMENT 'SKU ID',
  `deduction_type` tinyint(4) NOT NULL COMMENT '扣减类型:1-抢单扣减 2-取消回补 3-超时回补',
  `quantity` int(11) NOT NULL COMMENT '扣减数量(正数扣减,负数回补)',
  `order_id` bigint(20) DEFAULT NULL COMMENT '关联订单ID',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`),
  KEY `idx_product_sku` (`product_id`, `sku_id`),
  KEY `idx_order_id` (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存扣减流水表';

我的经验:库存扣减不要直接在 Redis 里扣完就完事了。Redis 里的库存是「预扣」,真正的库存扣减要等订单支付成功后,再写入 MySQL 的流水表。如果用户超时未支付,Redis 里的库存要回补,同时 MySQL 里也要记录一条回补流水。这样对账时才不会乱。

3.3 Redis 在抢单系统中的角色

Redis 在抢单系统里,不是用来存数据的,而是用来「挡流量」的。

说白了,Redis 就是一道防火墙。所有抢单请求先打到 Redis 上,Redis 说「有库存」,才允许请求继续往下走;Redis 说「卖完了」,直接返回失败,连 MySQL 的门都不让进。

3.3.1 库存预热与扣减

活动开始前,把商品库存从 MySQL 加载到 Redis。用 String 类型存储,key 为 stock:{product_id}:{sku_id},value 为剩余库存。

// 库存预热
String key = "stock:1001:2001";
redisTemplate.opsForValue().set(key, 100);

// 扣减库存(使用 Lua 脚本保证原子性)
String luaScript = "local stock = redis.call('GET', KEYS[1]) " +
                   "if stock and tonumber(stock) > 0 then " +
                   "  redis.call('DECR', KEYS[1]) " +
                   "  return 1 " +
                   "else " +
                   "  return 0 " +
                   "end";
Long result = redisTemplate.execute(
    new DefaultRedisScript<>(luaScript, Long.class),
    Collections.singletonList(key)
);

注意:千万别用 GET + DECR 两条命令分开执行。在高并发下,这两条命令之间会有时间窗口,导致超卖。一定要用 Lua 脚本或 Redis 的事务机制,保证原子性。

3.3.2 用户抢单状态记录

防止同一个用户重复抢单,可以用 Redis 的 Set 或 String 记录用户已抢状态。

// 记录用户已抢单(过期时间设为活动结束时间)
String userKey = "user:order:" + activityId + ":" + userId;
Boolean isSuccess = redisTemplate.opsForValue().setIfAbsent(userKey, "1", 1, TimeUnit.DAYS);
if (Boolean.FALSE.equals(isSuccess)) {
    // 用户已抢过,直接返回
    return "您已参与过该活动";
}

3.4 其他数据库的补充角色

MySQL 和 Redis 是主力,但有些场景它们并不擅长。

3.4.1 ClickHouse:订单分析查询

运营要看「今天哪个商品卖得最好」「哪个时段的抢单成功率最高」。这些查询如果直接在 MySQL 上跑,会把主库拖死。

我的做法是:把订单数据通过 Canal 同步到 ClickHouse。ClickHouse 的列式存储和向量化执行引擎,跑这种聚合查询简直是降维打击。

3.4.2 Elasticsearch:订单搜索

用户要查「我三个月前抢的那个手机订单」。MySQL 的 like 查询在数据量大时就是灾难。把订单数据同步到 ES,用倒排索引实现毫秒级搜索。

3.5 表结构设计避坑指南

最后,分享几个我踩过的坑:

  • 不要用 UUID 做主键。UUID 是随机字符串,在 InnoDB 中作为主键会导致页分裂,写入性能急剧下降。用雪花算法或自增 ID。
  • 不要在大字段上建索引。我曾经在 varchar(500) 的字段上建了索引,结果索引比数据还大,查询反而更慢。
  • 分表策略要提前想好。按 user_id 哈希分表,还是按 create_time 范围分表?我建议按 user_id 哈希分,这样同一个用户的所有订单都在同一张表里,查询方便。
  • 预留扩展字段。用 JSON 类型存一些不确定的扩展信息,避免频繁改表结构。

一句话总结:Redis 挡流量,MySQL 落数据,ClickHouse 做分析,ES 做搜索。各取所长,别让一个数据库干所有活。

抢单系统数据库架构 用户请求 Redis 热数据层 库存扣减 · 用户限流 · 抢单令牌 响应时间:< 5ms MySQL 持久化层 订单落地 · 库存流水 · 财务对账 响应时间:< 50ms ClickHouse 数据分析 Elasticsearch 热数据 冷数据 分析/搜索

这张图就是抢单系统数据库架构的核心逻辑。记住这个分层思想,你的系统在遇到高并发时,才能稳如老狗。


公众号:蓝海资料掘金营,微信deep3321