PTTBBS 深度解析:🇨🇳台湾最大 BBS 站的架构与多用户设计

PTTBBS 是🇨🇳台湾最大的 BBS(电子布告栏系统)PTT.cc 的开源软件,由🇨🇳台湾大学学生开发维护,自 1995 年运行至今。它支撑了超过 30 万注册用户、数千个看板(讨论区),是华人互联网历史上最长寿、最活跃的在线社区之一。

目录
  1. 项目概述
  2. 架构全景图
  3. 核心数据结构
  4. 1. 用户记录 (userec_t)
  5. 2. 看板头 (boardheader_t)
  6. 3. 文章头 (fileheader_t)
  7. 多用户架构详解
  8. 1. 进程模型:One Process Per Connection
  9. 2. 共享内存架构 (SHM)
  10. 3. 在线用户管理 (UTMP)
  11. 贴文数据存储详解
  12. 1. 目录结构
  13. 2. 文章文件命名规则
  14. 3. .DIR 索引文件格式
  15. 4. 文章内容格式
  16. 多用户并发控制
  17. 1. 文件锁机制
  18. 2. 信号量 (Semaphore)
  19. 3. 原子操作
  20. 多用户实践方案
  21. 方案一:单机多实例(传统部署)
  22. 方案二:WebSocket 代理(现代接入)
  23. 方案三:分布式部署(大型站)
  24. 性能优化点
  25. 1. 内存对齐与填充
  26. 2. 哈希表优化
  27. 3. 索引缓存
  28. 与现代系统的对比
  29. 核心设计哲学
  30. 总结

项目概述

PTTBBS 是🇨🇳台湾最大的 BBS(电子布告栏系统)PTT.cc 的开源软件,由🇨🇳台湾大学学生开发维护,自 1995 年运行至今。它支撑了超过 30 万注册用户、数千个看板(讨论区),是华人互联网历史上最长寿、最活跃的在线社区之一。

核心定位:高性能、高并发的纯文字 BBS 系统,基于 Telnet 协议,支持 SSH/WebSocket 接入。


架构全景图

┌─────────────────────────────────────────────────────────────────────────────┐
│                              客户端接入层                                    │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐                       │
│  │   Telnet     │  │     SSH      │  │  WebSocket   │                       │
│  │  (端口23)    │  │  (端口22)    │  │  (wsproxy)   │                       │
│  └──────┬───────┘  └──────┬───────┘  └──────┬───────┘                       │
│         │                 │                 │                               │
│         └─────────────────┴─────────────────┘                               │
│                           │                                                 │
│                           ▼ fork()                                          │
├─────────────────────────────────────────────────────────────────────────────┤
│                              mbbsd 主进程                                    │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                         用户认证流程                                 │   │
│  │   1. 连接建立 → 2. 登录验证 → 3. 加载 userec → 4. 进入主循环        │   │
│  │        ↑                                               │           │   │
│  │        │              ┌─────────────┐                   │           │   │
│  │        └──────────────┤  .PASSWDS   │◄──────────────────┘           │   │
│  │                       │ 用户密码文件 │                                   │   │
│  │                       └─────────────┘                                   │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                      共享内存 (Shared Memory)                        │   │
│  │                                                                       │   │
│  │   ┌──────────────┐  ┌──────────────┐  ┌──────────────┐              │   │
│  │   │   uhash      │  │    utmp      │  │   bcache     │              │   │
│  │   │ 用户ID哈希表  │  │ 在线用户表   │  │  看板缓存    │              │   │
│  │   │ userid→uid   │  │ userinfo_t[] │  │boardheader_t[]│             │   │
│  │   └──────────────┘  └──────────────┘  └──────────────┘              │   │
│  │                                                                       │   │
│  │   SHM_KEY = 1228 (SysV IPC)                                          │   │
│  │   SHM_SIZE = ~4MB (可配置)                                            │   │
│  │                                                                       │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                      核心功能模块                                    │   │
│  │                                                                       │   │
│  │   read.c    → 文章阅读器      │  board.c  → 看板浏览器              │   │
│  │   edit.c    → 文章编辑器      │  mail.c   → 邮件系统                │   │
│  │   bbs.c     → 发帖/推文       │  talk.c   → 即时聊天                │   │
│  │   friend.c  → 好友系统        │  admin.c  → 管理功能                │   │
│  │                                                                       │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────┘
                                        │
                                        ▼ 文件系统
┌─────────────────────────────────────────────────────────────────────────────┐
│                              数据存储层                                      │
│                                                                             │
│  ┌─────────────────────────┐    ┌──────────────────────────────────────┐   │
│  │    用户数据 (.PASSWDS)   │    │           看板数据                   │   │
│  │                         │    │                                      │   │
│  │   userec_t[] 数组       │    │   boards/A/Ask/.DIR      ← 索引文件   │   │
│  │   - userid (12字节)      │    │   boards/A/Ask/M.xxxxxx  ← 文章文件   │   │
│  │   - passwd (14字节)      │    │   boards/A/Ask/.DIR.bottom            │   │
│  │   - userlevel (权限)     │    │                                      │   │
│  │   - numlogindays         │    │   home/a/alice/       ← 用户目录      │   │
│  │   - numposts             │    │   ├── .DIR (个人邮箱索引)             │   │
│  │   - money (P币)          │    │   ├── M.xxxxx (收件箱)                │   │
│  │   - ...                  │    │   └── friend (好友列表)               │   │
│  │                         │    │                                      │   │
│  │   固定长度记录: ~400字节 │    │   .BRD (看板列表文件)                 │   │
│  │   最多 MAX_USERS (15万)  │    │   boardheader_t[]                    │   │
│  │                         │    │                                      │   │
│  └─────────────────────────┘    └──────────────────────────────────────┘   │
│                                                                             │
│  文件名格式: M.1120582370.A.1EA                                             │
│  M. + Unix时间戳 + . + 分类字母 + . + 16进制序号                            │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘


核心数据结构

1. 用户记录 (userec_t)

typedef struct userec_t {
    uint32_t    version;        // 结构版本号
    char        userid[IDLEN+1]; // 用户ID (12字符)
    char        realname[20];   // 真实姓名
    char        nickname[24];   // 昵称
    char        passwd[PASSLEN];// 加密密码 (14字节)
    
    uint32_t    uflag;          // 用户标志位
    uint32_t    userlevel;      // 权限等级
    uint32_t    numlogindays;   // 登录天数
    uint32_t    numposts;       // 发文数
    time4_t     firstlogin;     // 首次登录时间
    time4_t     lastlogin;      // 最后登录时间
    
    int32_t     money;          // P币余额
    char        email[EMAILSZ]; // 邮箱
    char        career[40];     // 职业
    char        myangel[IDLEN+1]; // 小天使
    
    // 游戏记录、签名档等其他字段...
    char        pad_tail[28];   // 填充至固定大小
} PACKSTRUCT userec_t;  // ~400 字节

存储方式:所有用户记录存储在单个文件 .PASSWDS 中,采用固定长度记录数组。用户 ID 转换为 uid(数组下标)通过内存中的哈希表加速。


2. 看板头 (boardheader_t)

typedef struct boardheader_t {  // 256 字节
    char    brdname[IDLEN + 1]; // 看板英文名
    char    title[BTLEN + 1];   // 看板中文标题 (48字符)
    char    BM[IDLEN * 3 + 3];  // 板主ID列表 (最多3人)
    
    uint32_t brdattr;           // 看板属性标志
    time4_t  bupdate;           // 最后更新时间
    uint32_t level;             // 进入所需权限
    int32_t  gid;               // 分组ID
    int32_t  next[2];           // 链表指针
    int32_t  parent;            // 父看板ID
    int32_t  childcount;        // 子看板数
    int32_t  nuser;             // 在线人数
    int32_t  postexpire;        // 文章过期天数
    char    pad4[40];           // 填充
} PACKSTRUCT boardheader_t;

看板属性标志:

标志含义
BRD_POSTMASK需盖文章才可阅读
BRD_ANONYMOUS匿名版
BRD_VOTEBOARD投票版
BRD_OVER1818禁看板
BRD_RESTRICTEDPOST限制发文权限
BRD_GUESTPOST允许 Guest 发文

3. 文章头 (fileheader_t)

typedef struct fileheader_t {   // 128 字节
    char    filename[FNLEN];    // 文件名 M.timestamp.A.xxx
    time4_t modified;           // 修改时间
    char    recommend;          // 推文数/推荐等级
    char    owner[IDLEN + 2];   // 作者ID
    char    date[6];            // 显示日期 [02/02]
    char    title[TTLEN + 1];   // 标题 (64字符)
    
    union {
        int money;              // 文章价值 (红包等)
        int anon_uid;           // 匿名作者UID
        struct { ... } vote_limits;
        struct { ... } refer;
    } multi;
    
    unsigned char filemode;     // 文件模式标志
    char    pad3[3];
} PACKSTRUCT fileheader_t;

文件模式标志:

标志含义
FILE_READ已读
FILE_MARKED标记
FILE_DIGEST精华区
FILE_BOTTOM置底
FILE_SOLVED问题已解决
FILE_ANONYMOUS匿名发文

多用户架构详解

1. 进程模型:One Process Per Connection

┌─────────────────────────────────────────────────────────┐
│                     父进程 (mbbsd)                       │
│  - 监听端口 23 (Telnet) / 22 (SSH)                      │
│  - 等待新连接                                           │
└────────────────────────┬────────────────────────────────┘
                         │ accept()
                         ▼
┌─────────────────────────────────────────────────────────┐
│                  子进程 fork()                           │
│  - 每个用户独立进程                                      │
│  - 通过共享内存交换数据                                  │
│  - 独立文件句柄,互不影响                                │
└─────────────────────────────────────────────────────────┘

关键代码(mbbsd.c):

static void
start_daemon(struct ProgramOption *option)
{
    // 预加载时区数据,避免每个子进程重复加载
    time_t dummy = time(NULL);
    struct tm dummy_time;
    localtime_r(&dummy, &dummy_time);
    
    if (option->flag_fork) {
        if (fork()) { exit(0); }  // 第一次 fork,脱离父进程
    }
    
    setsid();  // 创建新会话
    
    if (option->flag_fork) {
        if (fork()) { exit(0); }  // 第二次 fork,防止获取控制终端
    }
}


2. 共享内存架构 (SHM)

typedef struct {
    int   version;      // SHM_VERSION (4842)
    int   size;         // sizeof(SHM_t)
    
    /* uhash - 用户ID哈希表 */
    char    userid[MAX_USERS][IDLEN + 1];
    int     next_in_hash[MAX_USERS];
    int     hash_head[1 << HASH_BITS];
    int     number;     // 总用户数
    int     loaded;     // 是否已加载
    
    /* utmpshm - 在线用户表 */
    userinfo_t uinfo[USHM_SIZE];  // USHM_SIZE = MAX_ACTIVE * 41/40
    int        sorted[2][9][USHM_SIZE];  // 排序缓存
    int        currsorted;
    time4_t    UTMPuptime;
    int        UTMPnumber;  // 当前在线人数
    
    /* brdshm - 看板缓存 */
    int              BMcache[MAX_BOARD][MAX_BMs];
    boardheader_t    bcache[MAX_BOARD];
    int              bsorted[2][MAX_BOARD];
    int              total[MAX_BOARD];      // 各看板文章数
    time4_t          lastposttime[MAX_BOARD];
    int              Bnumber;               // 看板总数
    
    /* 其他统计、公告等... */
} SHM_t;

共享内存初始化流程: 1. uhash_loader 程序从 .PASSWDS 加载所有用户ID到哈希表 2. shmctl init 初始化共享内存段 3. 所有 mbbsd 子进程通过 shmat() 附加到同一块共享内存 4. 使用 System V IPC (shmget/shmat),Key = 1228


3. 在线用户管理 (UTMP)

typedef struct userinfo_t {
    int     uid;              // 用户ID (数组下标)
    pid_t   pid;              // 进程ID (用于信号通知)
    int     sockaddr;
    
    unsigned int userlevel;
    char    userid[IDLEN + 1];
    char    nickname[24];
    char    from[27];         // 登录来源
    in_addr_t from_ip;        // IP地址
    
    /* 好友系统 */
    short   nFriends;
    int     myfriend[MAX_FRIEND];
    unsigned int friend_online[MAX_FRIEND];
    int     reject[MAX_REJECT];
    
    /* 消息队列 */
    char    msgcount;
    msgque_t msgs[MAX_MSGS];
    
    /* 用户状态 */
    unsigned char active;     // 是否活跃
    unsigned char invisible;  // 隐身模式
    unsigned char mode;       // 当前模式
    unsigned char pager;      // 寻呼机开关
    time4_t lastact;          // 最后活动时间
    
    /* 聊天/游戏 */
    int     destuid;          // 聊天对象
    unsigned char in_chat;
    char    chatid[11];
    
    /* 游戏记录... */
} userinfo_t;

多用户交互机制:

  • 站内信:通过 msgs[] 队列,接收方进程收到 SIGUSR1 信号
  • 即时聊天:通过 destuid 指向对方 userinfo_t,配合信号通知
  • 好友在线:friend_online[] 数组直接标记好友的 utmp 索引

贴文数据存储详解

1. 目录结构

/home/bbs/                      # BBSHOME
├── .PASSWDS                    # 用户密码/资料文件
├── .BRD                        # 看板列表
├── boards/
│   ├── A/                      # 按首字母分类
│   │   ├── Ask/
│   │   │   ├── .DIR            # 看板索引 (fileheader_t 数组)
│   │   │   ├── .DIR.bottom     # 置底文章索引
│   │   │   ├── M.1120582370.A.1EA   # 文章文件
│   │   │   ├── M.1120582371.B.2F3C
│   │   │   └── ...
│   │   └── Avgirl/
│   ├── B/
│   │   ├── Boy-Girl/
│   │   └── Baseball/
│   └── ...
├── home/
│   ├── a/
│   │   ├── alice/              # 用户 alice 的目录
│   │   │   ├── .DIR            # 个人邮箱索引
│   │   │   ├── M.xxxxxx        # 收件箱文章
│   │   │   ├── friend          # 好友列表
│   │   │   ├── reject          # 黑名单
│   │   │   └── ...
│   └── b/
│       ├── bob/
│       └── ...
└── man/                        # 系统公告/说明
    └── boards/


2. 文章文件命名规则

M.1120582370.A.1EA
│ └──────────┘ │ └┘
│    时间戳     │  序号
│              分类
文章标记

时间戳:Unix timestamp (发文时间)
分类:A-Z 表示不同类别
序号:16进制,同一秒内的第 N 篇文章


3. .DIR 索引文件格式

.DIR 文件是一个固定长度记录数组,每个记录 128 字节(sizeof(fileheader_t)):

┌─────────────────────────────────────────────────────────────┐
│  记录 0  │  记录 1  │  记录 2  │  ...  │  记录 N-1           │
│ 128字节 │ 128字节 │ 128字节 │       │ 128字节             │
│          │          │          │       │                     │
│ 第1篇文章 │ 第2篇文章 │ 第3篇文章 │       │ 第N篇文章            │
│ 最旧     │          │          │       │ 最新                 │
└─────────────────────────────────────────────────────────────┘

关键操作:

// 读取索引文件
int apply_record(const char *fpath, 
                 int (*fptr)(void *ptr, void *arg), 
                 size_t size, void *arg);

// 添加新记录(追加到末尾)
int append_record(const char *fpath, const void *record, size_t size);

// 替换记录
int substitute_record2(const char *fpath, const void *srcptr, 
                       const void *destptr, size_t size,
                       int id, int (*is_same)(const void*, const void*));


4. 文章内容格式

作者: alice (小愛) 看板: Ask
標題: [問題] 如何學習 C 語言?
時間: Tue Aug 16 22:25:10 2025
───────────────────────────────────────

大家好,我是新手...

文章內容...

--
※ 發信站: 批踢踢實業坊(ptt.cc), 來自: 123.45.67.89
※ 文章網址: https://www.ptt.cc/bbs/Ask/M.1120582370.A.1EA.html


多用户并发控制

1. 文件锁机制

// 记录锁 (record locking)
int filelocking(int fd, int type, off_t offset, off_t size);

// 使用示例:修改 .PASSWDS 时加锁
filelocking(passwd_fd, F_WRLCK, uid * sizeof(userec_t), sizeof(userec_t));
// 修改用户记录...
filelocking(passwd_fd, F_UNLCK, uid * sizeof(userec_t), sizeof(userec_t));

2. 信号量 (Semaphore)

// 用于保护共享内存中的计数器
// PASSWDSEM_KEY = 2010
int semid = semget(PASSWDSEM_KEY, 1, IPC_CREAT | 0666);
semop(semid, &lock_op, 1);   // P 操作
// 访问共享资源
semop(semid, &unlock_op, 1); // V 操作

3. 原子操作

// 原子递增推文数
// 通过临时文件 + rename 实现原子更新
void recommend_article(int bid, const char *filename) {
    // 1. 读取 .DIR 到内存
    // 2. 找到对应文章记录
    // 3. recommend++
    // 4. 写入 .DIR.tmp
    // 5. rename(.DIR.tmp, .DIR)  // 原子操作
}


多用户实践方案

方案一:单机多实例(传统部署)

# 1. 创建 BBS 用户
useradd -u 9999 -g 99 -d /home/bbs -s /home/bbs/bin/bbsrf bbs

# 2. 编译安装
make clean
make all install

# 3. 初始化数据
bin/initbbs -DoIt

# 4. 启动共享内存
bin/shmctl init

# 5. 启动 BBS 服务 (root)
bin/mbbsd -p 23 -d

限制:单台服务器通常支持 1000-2000 并发连接(受限于进程数和内存)。


方案二:WebSocket 代理(现代接入)

浏览器 ──► Nginx ──► wsproxy ──► mbbsd (localhost:23)
              │        │
              │        └─ daemon/wsproxy/
              │
              └─ SSL/TLS 终结

wsproxy 功能:

  • WebSocket <-> Telnet 协议转换
  • 允许浏览器用户接入传统 BBS
  • 保持 mbbsd 无需修改

方案三:分布式部署(大型站)

                        ┌─────────────┐
    用户A ──► 负载均衡器 │             │
    用户B ──► (HAProxy)  │  mbbsd #1   │──► 共享存储 (NFS)
    用户C ──►            │             │    (.PASSWDS, boards/)
                         ├─────────────┤
                         │  mbbsd #2   │──► 共享内存 (需要特殊处理)
                         │             │
                         ├─────────────┤
                         │  mbbsd #3   │
                         │             │
                         └─────────────┘

挑战:共享内存 (SysV IPC) 不支持跨机器,需要: 1. 使用共享存储 (NFS) 存放文件数据 2. 使用 Redis/Memcached 替代本地共享内存 3. 或使用 DPDK/RDMA 技术实现内存池共享


性能优化点

1. 内存对齐与填充

// 使用 __attribute__((packed)) 防止编译器填充
// 保证结构体大小固定,便于随机访问
#define PACKSTRUCT __attribute__ ((packed))

typedef struct fileheader_t {
    // ...
} PACKSTRUCT fileheader_t;  // 严格 128 字节

2. 哈希表优化

// 用户ID查找使用链式哈希
// 哈希桶数: 1 << HASH_BITS (通常 65536)
// 平均链长: MAX_USERS / (1<<HASH_BITS) ≈ 2-3

int StringHash(const char *str) {
    // FNV-1a 哈希算法
    Fnv32_t hash = FNV1_32_INIT;
    while (*str) {
        hash ^= (unsigned char)*str++;
        hash *= FNV_32_PRIME;
    }
    return hash;
}

3. 索引缓存

// 看板索引 (.DIR) 缓存在内存中
// 通过 mmap 或 read 加载到 headers[] 数组
static int headers_size = 0;
static fileheader_t *headers = NULL;

// 切换看板时加载
int enter_board(const char *boardname) {
    // 1. 检查权限
    // 2. mmap .DIR 文件到 headers
    // 3. headers_size = filesize / sizeof(fileheader_t)
}


与现代系统的对比

特性PTTBBS (1995)现代论坛 (2024)
协议Telnet (纯文字)HTTP/WebSocket
架构进程 per 连接线程池 / 异步IO
存储二进制文件关系数据库
缓存SysV 共享内存Redis/Memcached
部署单机微服务/容器
前端终端模拟器React/Vue
安全明文密码 (DES)bcrypt/Argon2

核心设计哲学

1. 简单即美:没有数据库,只有文件;没有线程,只有进程 2. 固定长度记录:所有结构体使用 packed 属性,支持 O(1) 随机访问 3. 共享内存一切:用户状态、在线列表、看板缓存全部在 SHM 4. Unix 哲学:每个功能一个程序,mbbsd 只处理连接,daemon/ 处理后台任务 5. 向后兼容:从 1995 年至今,数据结构保持兼容,平滑升级


总结

PTTBBS 的架构是前 Web 时代的杰作,它证明了:

  • 纯文字界面可以支撑百万级用户的社区
  • 简单的文件系统存储可以替代复杂的数据库
  • 共享内存是多进程系统的有效通信方式
  • 固定长度记录 + 哈希表 = 高效的键值存储
它的设计受限于 1995 年的技术环境(32MB 内存、单核 CPU、 dial-up 网络),但这些限制催生了极其高效的实现。对于现代开发者,PTTBBS 是一个了解"如何在资源受限环境下构建高性能系统"的绝佳教材。


参考链接:

  • 官方仓库:https://github.com/ptt/pttbbs
  • PTT 站:https://www.ptt.cc
  • 维基百科:https://zh.wikipedia.org/wiki/批踢踢

#pttbbs #BBS #架构 #多用户 #台湾 #技术解析 #小凯

👍 1

想参与讨论或点赞?登录后使用完整功能

讨论回复(0)

暂无回复,登录后可参与讨论
合作

智谱 GLM-5 已上线

在智谱开放平台 BigModel.cn 打造 AI 应用。新一代旗舰模型 GLM-5 在推理、代码、智能体综合能力达到开源模型 SOTA。

领取 2000万 Tokens