3、会话管理与登录:初始化交易会话、登录流程详解、登录状态机、心跳机制与重连策略

做量化交易,说白了就是跟交易所的服务器打交道。你写的策略再牛,如果连不上服务器,一切都是白搭。这一章咱们就聊聊「会话」这件事——怎么连、怎么登、怎么保活、断了怎么重连。

我个人习惯把交易系统的会话管理看作一个「状态机」。嗯,没错,就是那种有状态、有转移、有边界的东西。你想想看,一个连接不可能永远在线,它总得经历:未连接 → 已连接 → 已登录 → 已断开 → 重连中…… 这一套流程。

核心观点: 会话管理不是写一个 connect() 就完事了。它是一个需要精心设计的子系统,直接决定了你的交易程序能否稳定运行 7x24 小时。

3.1 初始化交易会话

初始化会话,我习惯叫它「握手前的准备」。这一步其实很简单,但很多人会忽略细节。

举个例子,我在对接 CTP 接口时,第一步不是直接发登录请求,而是先创建一个 CTPTradeAdapter 实例,然后调用 Init() 方法。这个 Init() 背后做了什么?

  • 加载配置文件(交易前置地址、经纪商代码、用户ID、密码、行情前置地址等)
  • 创建网络连接池(如果是长连接)
  • 注册回调函数(OnFrontConnected、OnRspUserLogin 等)
  • 设置心跳超时参数
// 伪代码示例:初始化交易会话
void InitTradeSession(const Config& cfg) {
    // 1. 创建交易适配器
    auto adapter = std::make_shared<CTPTradeAdapter>();
    
    // 2. 注册回调
    adapter->RegisterSpi(this);
    
    // 3. 初始化(内部会连接前置机)
    int ret = adapter->Init(cfg.front_addr.c_str());
    if (ret != 0) {
        // 我曾经在这里踩过坑:Init 失败后直接重试,结果导致内存泄漏
        // 正确做法:先释放资源,再重新创建
        LogError("Init failed, ret=%d", ret);
        return;
    }
    
    // 4. 等待 OnFrontConnected 回调
    // 这里一般用条件变量或 Promise 来同步等待
    std::unique_lock<std::mutex> lock(mutex_);
    cv_.wait_for(lock, std::chrono::seconds(5), [this]{
        return connected_;
    });
}
小技巧: 初始化时,我建议把超时时间设得短一点(比如 5 秒)。如果 5 秒连不上,直接报错,不要死等。这样能快速暴露网络问题。

3.2 登录流程详解

登录,说白了就是向交易所证明「我是谁」。但这个过程比你想的要复杂一点。

标准的登录流程是这样的:

  1. 前置连接成功(OnFrontConnected 回调触发)
  2. 发送登录请求(ReqUserLogin)
  3. 等待登录响应(OnRspUserLogin)
  4. 确认登录成功(检查错误码和会话参数)
  5. 获取交易日信息(有些接口需要额外请求交易日)

这里有个坑,我遇到过好几次。有些交易所的登录响应不是一次性返回的。比如 CTP,它会先返回一个 OnRspUserLogin,告诉你登录成功了,但紧接着还会返回一个 OnRtnTradingDay,告诉你当前交易日。如果你只处理了第一个回调,忽略了第二个,那你的系统可能拿不到正确的交易日。

// 登录请求示例
void OnFrontConnected() {
    // 前置连接成功,开始登录
    CThostFtdcReqUserLoginField req = {0};
    strcpy(req.BrokerID, broker_id_.c_str());
    strcpy(req.UserID, user_id_.c_str());
    strcpy(req.Password, password_.c_str());
    
    int ret = adapter_->ReqUserLogin(&req, 0);
    if (ret != 0) {
        // 登录请求发送失败,需要重试
        LogError("ReqUserLogin failed, ret=%d", ret);
        // 我一般会在这里启动一个重试定时器
        StartRetryTimer();
    }
}

void OnRspUserLogin(CThostFtdcRspUserLoginField* pRsp, 
                    CThostFtdcRspInfoField* pRspInfo, 
                    int nRequestID, bool bIsLast) {
    if (pRspInfo && pRspInfo->ErrorID != 0) {
        // 登录失败
        LogError("Login failed: %s", pRspInfo->ErrorMsg);
        HandleLoginFailure(pRspInfo->ErrorID);
        return;
    }
    
    // 登录成功,保存会话参数
    session_id_ = pRsp->SessionID;
    front_id_ = pRsp->FrontID;
    trading_day_ = pRsp->TradingDay;
    
    LogInfo("Login success, session=%d, front=%d, day=%s", 
            session_id_, front_id_, trading_day_);
}
注意: 登录失败的原因有很多。密码错误、账户冻结、IP 白名单限制、并发登录限制…… 我建议你把常见的错误码整理成一张表,方便快速定位问题。

3.3 登录状态机

为什么要用状态机?因为登录过程不是线性的。你可能会遇到:连接断开、重连、登录超时、重复登录…… 如果没有状态机,代码会变成一团乱麻。

我设计的状态机一般包含这几个状态:

状态 说明 触发条件
DISCONNECTED 未连接 初始状态 / 连接断开后
CONNECTING 正在连接 调用 Init() 后
CONNECTED 已连接,未登录 OnFrontConnected 触发
LOGIN_SENT 已发送登录请求 调用 ReqUserLogin 后
LOGIN_SUCCESS 登录成功 OnRspUserLogin 成功
RECONNECTING 正在重连 连接断开后自动触发

下面是我画的一张状态机图,你可以直观地看到状态之间的流转关系:

DISCONNECTED CONNECTING CONNECTED LOGIN_SENT LOGIN_SUCCESS RECONNECTING Init() OnFrontConnected ReqUserLogin OnRspUserLogin 成功 登录失败/超时 连接断开 自动重连 重连成功 重连失败 正常流转 异常/失败 重连流程
我的经验: 状态机一定要用枚举或常量定义,不要用魔法数字。我曾经在一个项目里看到有人用 0、1、2、3 表示状态,结果后来加了一个状态,整个代码都乱了。用枚举,清晰又安全。

3.4 心跳机制与重连策略

登录成功后,你以为就万事大吉了?不,真正的挑战才刚刚开始。网络是不稳定的,交易所的服务器也可能重启。你需要一套机制来保活连接。

心跳机制,说白了就是定时给服务器发一个「我还活着」的信号。如果服务器长时间没收到心跳,它会认为客户端已经断开,然后主动关闭连接。

我一般这样设计心跳:

  • 发送频率: 每 30 秒发送一次心跳请求(具体看交易所要求)
  • 超时判断: 如果连续 3 次心跳没有收到响应,认为连接已断开
  • 心跳内容: 有些接口有心跳专用 API(如 CTP 的 ReqHeartBeat),有些则用查询类请求代替
// 心跳定时器示例
void StartHeartBeat() {
    heart_beat_timer_ = std::make_shared<Timer>();
    heart_beat_timer_->Start(30000, [this]() {
        // 每 30 秒发送一次心跳
        int ret = adapter_->ReqHeartBeat(nullptr, 0);
        if (ret != 0) {
            heart_beat_fail_count_++;
            if (heart_beat_fail_count_ >= 3) {
                // 连续 3 次心跳失败,触发重连
                LogError("Heart beat failed 3 times, start reconnecting");
                StartReconnect();
            }
        } else {
            heart_beat_fail_count_ = 0;
        }
    });
}

重连策略,这个我踩过不少坑。最 naive 的做法是:断开后立即重连。但这样会带来两个问题:

  1. 服务器压力: 如果服务器正在重启,你疯狂重连只会加重它的负担
  2. 死循环: 如果网络一直不通,你的程序会陷入「断开-重连-断开」的死循环

我现在的做法是「指数退避 + 随机抖动」:

重连次数 等待时间 说明
第 1 次 1 秒 快速重连
第 2 次 2 秒 翻倍
第 3 次 4 秒 继续翻倍
第 4 次 8 秒 继续翻倍
第 5 次 16 秒 继续翻倍
第 6 次及以上 30 秒(上限) 不再增加

每次等待时间还要加上一个随机值(比如 0~3 秒),防止多个客户端同时重连造成「惊群效应」。

// 重连策略实现
void StartReconnect() {
    if (reconnect_count_ > MAX_RECONNECT_COUNT) {
        LogError("Reconnect failed after %d attempts, giving up", MAX_RECONNECT_COUNT);
        // 通知上层:需要人工介入
        NotifyAdmin("Connection lost, manual intervention required");
        return;
    }
    
    // 计算等待时间:指数退避
    int wait_time = std::min(1 << reconnect_count_, 30);  // 上限 30 秒
    // 加上随机抖动
    int jitter = rand() % 3000;  // 0~3 秒
    wait_time = wait_time * 1000 + jitter;
    
    LogInfo("Reconnecting in %d ms (attempt %d)", wait_time, reconnect_count_ + 1);
    
    reconnect_timer_->Start(wait_time, [this]() {
        reconnect_count_++;
        // 重新初始化会话
        InitTradeSession(config_);
    });
}
注意: 重连成功后,你需要重新登录、重新订阅行情、重新查询持仓…… 总之,要把所有状态恢复到登录成功时的样子。我见过有人重连后忘了重新订阅行情,结果策略跑了一天,持仓数据全是旧的。

嗯,会话管理这块内容其实挺多的。但核心就三点:初始化要稳、登录要全、重连要聪明。你把这三点做好了,交易系统的稳定性至少能提升一个档次。

最后说一句,心跳和重连的日志一定要打全。我每次排查线上问题,第一件事就是看心跳日志。如果发现心跳中断了 10 分钟才重连,那肯定是策略有问题。

总结: 会话管理不是一次性工作,而是一个持续的过程。从初始化到登录,从心跳到重连,每一步都需要精心设计。别怕麻烦,这些代码写好了,能帮你省下无数个熬夜排查问题的夜晚。

专注资料整理