数据库设计与建模:用户表、订单表、抢单记录表

做抢单系统,数据库设计是地基。地基没打好,后面并发一上来,各种死锁、数据不一致的问题就全冒出来了。我个人习惯是先画清楚业务关系,再动手建表。今天我们就来聊聊这三张核心表的设计思路。

一、业务关系梳理

先想清楚业务逻辑:

  • 用户可以发布订单,也可以抢别人的订单
  • 订单只能被一个用户抢到,抢到后状态变更
  • 抢单记录记录每一次抢单行为,不管成功还是失败

说白了,这是一个「多对多」的关系,但中间有状态约束。用户和订单之间,通过抢单记录表建立联系。我曾在项目中遇到过一个问题:没有单独设计抢单记录表,直接把抢单信息塞在订单表里,结果导致订单表字段爆炸,查询性能直线下降。嗯,这个坑你们别踩。

核心关系总结:

  • 用户 1 : N 订单(用户发布订单)
  • 用户 1 : N 抢单记录(用户参与抢单)
  • 订单 1 : N 抢单记录(一个订单被多次抢)
  • 抢单记录 M : 1 用户 + M : 1 订单

二、三张表的详细设计

1. 用户表(users)

用户表相对简单,但要注意几个关键字段。我建议把用户类型、信用分、状态这些业务字段都放进来,方便后续做权限控制和风控。

CREATE TABLE users (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID',
    username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名',
    password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希',
    phone VARCHAR(20) COMMENT '手机号',
    user_type TINYINT NOT NULL DEFAULT 1 COMMENT '用户类型: 1-普通用户 2-商家',
    credit_score INT DEFAULT 100 COMMENT '信用分',
    status TINYINT DEFAULT 1 COMMENT '状态: 1-正常 0-禁用',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_phone (phone),
    INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

设计心得:信用分字段别看小,抢单系统的核心公平机制就靠它。我曾经在项目里没设计信用分,结果被刷单党薅羊毛薅到服务器崩溃。后来加上信用分和抢单频率限制,才稳住局面。

2. 订单表(orders)

订单表是核心业务表,字段比较多。这里要注意的是状态字段的设计,以及并发控制相关的版本号或锁字段。

CREATE TABLE orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '订单ID',
    order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '订单编号',
    publisher_id BIGINT NOT NULL COMMENT '发布者用户ID',
    title VARCHAR(200) NOT NULL COMMENT '订单标题',
    description TEXT COMMENT '订单描述',
    reward DECIMAL(10,2) NOT NULL COMMENT '赏金金额',
    status TINYINT NOT NULL DEFAULT 0 COMMENT '状态: 0-待抢 1-已被抢 2-已完成 3-已取消',
    grabber_id BIGINT DEFAULT NULL COMMENT '抢单者用户ID',
    version INT DEFAULT 0 COMMENT '乐观锁版本号',
    expired_at DATETIME COMMENT '过期时间',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_publisher (publisher_id),
    INDEX idx_status (status),
    INDEX idx_expired (expired_at),
    FOREIGN KEY (publisher_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意:version字段是乐观锁的关键。在高并发抢单场景下,直接用数据库行锁容易造成死锁。我建议用乐观锁+重试机制,性能会好很多。你想想看,如果1000个人同时抢一个单,用行锁的话数据库得排队到什么时候?

3. 抢单记录表(grab_records)

这张表记录每一次抢单行为,不管成功还是失败。为什么失败也要记录?因为要做风控分析。我曾经遇到过有人写脚本每秒抢100次,如果没有失败记录,你根本发现不了这种异常行为。

CREATE TABLE grab_records (
    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '记录ID',
    order_id BIGINT NOT NULL COMMENT '订单ID',
    user_id BIGINT NOT NULL COMMENT '抢单用户ID',
    result TINYINT NOT NULL COMMENT '抢单结果: 1-成功 0-失败',
    fail_reason VARCHAR(100) DEFAULT NULL COMMENT '失败原因',
    grab_time DATETIME NOT NULL COMMENT '抢单时间',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_order (order_id),
    INDEX idx_user (user_id),
    INDEX idx_grab_time (grab_time),
    FOREIGN KEY (order_id) REFERENCES orders(id),
    FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

三、表关系与ER图

三张表的关系其实很清晰。用户和订单是一对多,用户和抢单记录也是一对多,订单和抢单记录还是一对多。抢单记录表就是中间表,把用户和订单关联起来。

下面我用SVG画了一张关系图,方便你理解:

users id (PK) username, phone, ... orders id (PK) order_no, status, ... grab_records id (PK) order_id (FK), user_id (FK) 1 N 1 N 1 N

四、SQLAlchemy ORM映射

表设计好了,接下来用SQLAlchemy做ORM映射。我个人比较喜欢用声明式映射方式,代码清晰,维护方便。

from sqlalchemy import create_engine, Column, Integer, String, DateTime, DECIMAL, Text, ForeignKey
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from datetime import datetime

Base = declarative_base()

class User(Base):
    __tablename__ = 'users'
    
    id = Column(Integer, primary_key=True, autoincrement=True)
    username = Column(String(50), unique=True, nullable=False)
    password_hash = Column(String(255), nullable=False)
    phone = Column(String(20))
    user_type = Column(Integer, default=1)  # 1-普通用户 2-商家
    credit_score = Column(Integer, default=100)
    status = Column(Integer, default=1)
    created_at = Column(DateTime, default=datetime.now)
    updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
    
    # 关系映射
    orders = relationship("Order", back_populates="publisher")
    grab_records = relationship("GrabRecord", back_populates="user")

class Order(Base):
    __tablename__ = 'orders'
    
    id = Column(Integer, primary_key=True, autoincrement=True)
    order_no = Column(String(32), unique=True, nullable=False)
    publisher_id = Column(Integer, ForeignKey('users.id'), nullable=False)
    title = Column(String(200), nullable=False)
    description = Column(Text)
    reward = Column(DECIMAL(10,2), nullable=False)
    status = Column(Integer, default=0)  # 0-待抢 1-已被抢 2-已完成 3-已取消
    grabber_id = Column(Integer, default=None)
    version = Column(Integer, default=0)  # 乐观锁
    expired_at = Column(DateTime)
    created_at = Column(DateTime, default=datetime.now)
    updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
    
    publisher = relationship("User", back_populates="orders")
    grab_records = relationship("GrabRecord", back_populates="order")

class GrabRecord(Base):
    __tablename__ = 'grab_records'
    
    id = Column(Integer, primary_key=True, autoincrement=True)
    order_id = Column(Integer, ForeignKey('orders.id'), nullable=False)
    user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
    result = Column(Integer, nullable=False)  # 1-成功 0-失败
    fail_reason = Column(String(100))
    grab_time = Column(DateTime, nullable=False)
    created_at = Column(DateTime, default=datetime.now)
    
    order = relationship("Order", back_populates="grab_records")
    user = relationship("User", back_populates="grab_records")

小技巧:在ORM中定义relationship时,记得加上back_populates参数,这样双向关联查询会非常方便。比如你想查某个用户抢过的所有订单,直接 user.grab_records 就能拿到,不用再写SQL。

五、避坑指南

做数据库设计这么多年,有些坑我踩过,你们就别再踩了:

  • 字段类型别乱用:金额字段一定要用DECIMAL,别用FLOAT。FLOAT有精度问题,算钱会出大事。我曾经因为这个问题导致对账不平,查了三天才找到原因。
  • 索引不是越多越好:每个表建3-5个索引就够了。索引太多会影响写入性能,抢单系统写入频率很高,索引多了反而慢。
  • 外键要慎用:虽然我上面建了外键,但在高并发场景下,外键会影响性能。如果并发量特别大,可以考虑在应用层维护数据一致性,数据库层面只建索引不建外键。
  • 时间字段统一用DATETIME:别混用TIMESTAMP和DATETIME,否则跨时区查询时会疯掉。我建议全部用DATETIME,应用层统一处理时区。

好了,数据库设计这块就聊到这里。这三张表是抢单系统的核心,设计好了,后面的业务逻辑实现起来就顺畅多了。记住,好的数据库设计是改出来的,不是一次就能完美的。上线后根据实际业务情况,该加索引加索引,该拆表拆表,灵活调整才是王道。

专注资料整理