项目实战5:聊天室

目录

项目运行

项目框架

模块逻辑

业务逻辑

数据存储

代码编写

[1 情景在线](#1 情景在线)

[2 HTTP请求(http_handler.h)](#2 HTTP请求(http_handler.h))

2.1注册/登录模块(http_conn.c)

2.1.1注册(api_reg.cc)

(1)解析

(2)验证

(3)返回

2.1.2登录(api_login.cc)

(1)解析

(2)验证

(3)返回

2.2聊天模块(websocket_conn.cc)

2.2.1建立websocket连接

(1)握手

(2)检验cookie

(3)发送"hello"信息

2.2.2处理聊天消息

(1)房间管理

(2)消息发送

2.2.3请求历史消息

2.2.4创建聊天室

(1)业务处理

(2)提供数据的api

[3 分布式](#3 分布式)

上线前项目存在的问题

面试题


项目运行

1.git clone http://gitlab.0voice.com/2410_vip/github-chatroom.git

2.进入server目录登录MySQL,导入数据库(确保已经安装mysql)

3.安装第三方库:libjsoncpp-dev(用于解析和生成json数据)、libhiredis-dev(服务端需要作为客户端利用Redis提供的hiredis访问redis服务器)、uuid-dev( 项目需要使用UUID 生成功能)

-- 注意hiredis0.14.0与Ubuntu20冲突,需要改为0.14.1的版本

4.安装redis-server:redis的服务端也配置在同一台虚拟机上

5.server端编译运行 项目的后台搭建成功,可以处理前端业务

6.安装Node.js,为web客户服务端提供环境支持

7.npm install安装web客户服务端需要的组件,"npm run dev"运行web客户端。直接在浏览器访问。

项目框架

模块逻辑

同一个端口,如何区分websocket和http服务?

业务逻辑

数据json格式:

数据存储

MySQL: 存储 "用户信息" ,在chatroom数据库中对应的user表

Redis :存储 "房间消息" 和 "用户cookie"

房间消息:使用redis的stream结构,key为消息id,value为具体的聊天消息

其中消息id = 时间戳 + 序列号 序列号用于区分1毫秒内的消息。

用户cookie:使用redis的string结构,key为cookie,value为用户id。为一个临时键值对,默认有效期是1天,超过则会被redis删除,需要重新登陆。

从Redis获取消息举例

Redis> XREVRANGE mystream (1736936461668-0 - COUNT 5

解析:

1、XREVRANGE : 命令,指逆序(新->旧)读取stream消息

2、mystream :stream是Redis中严格按照时间排序的数据结构,可以看作一个持续更新的记事本,而mystream 就是这个记事本的名字,除此之外可以用 order_stream 存订单,log_stream 存日志

1、1736936461668指从1970.1.1到发送消息时间的毫秒数

2、( 表示不包含1736936461668标识的这条消息,此消息后的5条消息

代码编写

1 情景在线

用户A注册账号,建立http服务,解析json数据,验证信息在数据库不存在且存入redis后,给用户A返回一个cookie,限时一天,用户A可以拿着cookie与服务器发送消息,期间无需再验证密码。登录成功后,用户A给服务器发送握手信息,请求Websocket服务,建立连接。服务端在检验cookie有效后,把用户A的ID和连接建立map映射,并且给其订阅默认的五个聊天室,随后加载五个聊天室的历史数据返回给用户A,尽管没有数据。用户A收到数据,知道连接建立成功。随后用户A在1号聊天室发布一条消息,服务器收到A通过websocket发来的websocket数据帧,按照websocket格式解析出frame,再通过json反序列化获取payload_data,把这个data存入redis数据库后,消息就算是落盘了,再执行一遍相反的包装操作,把这个websocket数据帧发送给其余也订阅了1号聊天室的用户,尽管这时候只有用户A。

用户B注册账号,经历上面的阶段后,会在连接建立成功的时候看到1号聊天室用户A发送的消息,然后在里面也发送一条消息,这个时候经历上面的 decode - store - encode - send的阶段,A就会看到这条消息。

用户B随后创建一个6号聊天室,这个聊天室信息存入磁盘后,会自动把在线的用户A和用户B拉入到房间里,在服务器重启后,由于落盘了,6号聊天室也会变成默认聊天室,所有新登录的用户都会自动订阅这个聊天室,并且会看到历史聊天数据。

2 HTTP请求(http_handler.h)

1.接收到HTTP请求,通过http header选择 websocket或者 http服务

cpp 复制代码
//http_handler.h

void OnRead(Buffer* buf) {
        if(request_type_ == UNKNOWN) {
            const char *in_buf = buf->peek();
            int32_t len = buf->readableBytes();
            std::cout << "in_buf: " << in_buf << std::endl;

            auto headers = parseHttpHeaders(in_buf, len);   //解析http头
            if (isWebSocketRequest(headers)) {
                // WebSocket 请求
                request_type_ = WEBSOCKET;
                http_conn_ = std::make_shared<CWebSocketConn>(tcp_conn_);
                http_conn_->setHeaders(headers);
            } else {
                // HTTP 请求
                request_type_ = HTTP;
                http_conn_ = std::make_shared<CHttpConn>(tcp_conn_);
                http_conn_->setHeaders(headers);
            }
        }
        // 将数据交给具体的处理器
        if(http_conn_)
            http_conn_->OnRead(buf);
}

2.1注册/登录模块(http_conn.c)

2.注册/登录是http服务,接下来需要解析HTTP请求的URL的内容,判断是登录还是注册

cpp 复制代码
//http_conn.c
//解析HTTP请求的内容    根据请求的URL判断是登录还是注册

void CHttpConn::OnRead(Buffer *buf) // CHttpConn业务层面的OnRead
{
    const char *in_buf = buf->peek();
    int32_t len = buf->readableBytes();
    
    http_parser_.ParseHttpContent(in_buf, len);
    if(http_parser_.IsReadAll()) {
        string url = http_parser_.GetUrlString();
        string content = http_parser_.GetBodyContentString();
        LOG_INFO << "url: " << url << ", content: " << content;   

        if (strncmp(url.c_str(), "/api/login", 10) == 0) { // 登录
            _HandleLoginRequest(url, content);
        }   
        else if(strncmp(url.c_str(), "/api/create-account", 18) == 0) {   //  注册
            _HandleRegisterRequest(url, content);
        } 
        
        else {//处理未匹配的URL
            char *resp_content = new char[256];
            string str_json = "{\"code\": 1}"; 
            uint32_t len_json = str_json.size();
            //暂时先放这里
            #define HTTP_RESPONSE_REQ                                                     \
                "HTTP/1.1 404 OK\r\n"                                                      \
                "Connection:close\r\n"                                                     \
                "Content-Length:%d\r\n"                                                    \
                "Content-Type:application/json;charset=utf-8\r\n\r\n%s"
            snprintf(resp_content, 256, HTTP_RESPONSE_REQ, len_json, str_json.c_str()); 	
            tcp_conn_->send(resp_content);
        }
       
    }
 
}

附:下面3.1与3.2都会用到的一段代码,cookie的生成以及存入redis的相关代码:

cpp 复制代码
//api_common.cc
int ApiSetCookie(string  email, string &cookie){
    // 获取redis连接池
    CacheManager *cache_manager = CacheManager::getInstance();
    CacheConn *cache_conn = cache_manager->GetCacheConn("token");  //
    AUTO_REL_CACHECONN(cache_manager, cache_conn);

    if(!cache_conn) {
        LOG_ERROR << "get cache conn failed";
        return -1;
    }
    
    cookie = generateUUID();    //需要唯一性
    string ret = cache_conn->SetEx(cookie, 86400, email); //存入redis  key为cookie value为email   86400s = 24h
    if(!ret.empty()) {
        return 0;
    } else {
        return -1;
    }
}
2.1.1注册(api_reg.cc)
(1)解析

解析注册请求post_data中的 JSON 数据

cpp 复制代码
//api_reg.cc
int decodeRegisterJson(const std::string &str_json, string &username,
                       string &email, string &password)
{   
    bool res;
    Json::Value root;
    Json::Reader jsonReader;
    res = jsonReader.parse(str_json, root);    
    if (!res) {
        LOG_ERROR << "parse reg json failed ";
        return -1;
    }
    // 用户名
    if (root["username"].isNull()) {
        LOG_ERROR << "username null";
        return -1;
    }
    username = root["username"].asString();

   //邮箱  
    if (root["email"].isNull()) {
        LOG_WARN << "email null";
    } else {
        email = root["email"].asString();
    }

    //密码
    if (root["password"].isNull()) {
        LOG_ERROR << "password null";
        return -1;
    }
    password = root["password"].asString();

    return 0;
}
(2)验证

验证用户信息(如用户名和邮箱是否已存在),并将用户信息(密码加密后)存储到数据库中。

cpp 复制代码
//api_reg.cc
int registerUser(string &username, string &email, string &password, api_error_id &error_id) {
    int ret = -1;
    //创建连接池和MySQL连接
    CDBManager *db_manager = CDBManager::getInstance();
    CDBConn *db_conn = db_manager->GetDBConn("chatroom_master");
    AUTO_REL_DBCONN(db_manager, db_conn);
    if(!db_conn) {
        LOG_ERROR << "GetDBConn(chatroom_master) failed" ;
        return 1;
    }
    // 先查询 用户名  邮箱是否存在 如果存在就报错
    string str_sql = FormatString("select id, username, email from users where username='%s' or email='%s' ", username.c_str(), email.c_str());

    CResultSet *result_set = db_conn->ExecuteQuery(str_sql.c_str());
    if(result_set && result_set->Next()) {
        if(result_set->GetString("username")) {
            if(result_set->GetString("username") == username)  {
                error_id = api_error_id::username_exists;
                LOG_WARN << "id: " << result_set->GetInt("id") << ", username: " <<  username <<  "  已经存在";
            }
           
        }
        if(result_set->GetString("email")) {
            if(result_set->GetString("email") == email) {
                error_id = api_error_id::email_exists;
                LOG_WARN << "id: " << result_set->GetInt("id") << ", email: " <<  email <<  "  已经存在";
            }
        }
        delete result_set;
        return -1;
    }

    //注册账号
    // 随机数生成盐值
    string salt = RandomString(16);  //随机混淆码
    MD5 md5(password+salt);
    string password_hash = md5.toString();
    LOG_INFO << "salt: " << salt;

    //插入语句
    str_sql = "insert into users  (`username`,`email`,`password_hash`,`salt`) values(?,?,?,?)";
    LOG_INFO << "执行: " <<  str_sql;
    // 预处理方式写入数据
    CPrepareStatement *stmt = new CPrepareStatement();
    if (stmt->Init(db_conn->GetMysql(), str_sql)) {
        uint32_t index = 0;
        stmt->SetParam(index++, username);
        stmt->SetParam(index++, email);
        stmt->SetParam(index++, password_hash);
        stmt->SetParam(index++, salt);
        bool bRet = stmt->ExecuteUpdate(); //真正提交要写入的数据
        if (bRet) {     //提交正常返回 true
            ret = 0;
            LOG_INFO << "insert user_id: " <<  db_conn->GetInsertId() <<  ", username: " <<  username ;
        } else {
            LOG_ERROR << "insert users failed. " <<  str_sql;
            ret = 1;
        }
    }
    delete stmt;
    return ret;
}
(3)返回

注册成功返回cookie,失败返回已存在。(在http_conn.c中有调用下面的函数)

cpp 复制代码
//api_reg.cc
int ApiRegisterUser(std::string &post_data, std::string &resp_data){
    string username;
    string email;
    string password;

    int ret = decodeRegisterJson(post_data, username, email, password);
    if(ret < 0) {
        // 参数问题的encode
        encdoeRegisterJson(api_error_id::bad_request, "请求参数不全", resp_data);
        return -1;
    }

    // 封装registerUser(username email password)
    api_error_id error_id = api_error_id::bad_request;
    ret = registerUser(username, email, password, error_id);

    //返回注册结果
    if(ret == 0) {
        // 注册成功 是需要产生cookie
        ApiSetCookie(email, resp_data);
    } else {
        // 注册问题的encode
        encdoeRegisterJson(error_id, api_error_id_to_string(error_id), resp_data);
    }

    //正常注册  返回cookie  在resp_data中

    //异常 {}   EMAIL_EXISTS 也在resp_data中    返回给http_conn.cc中传入的resp_data
    return ret;
}
2.1.2登录(api_login.cc)
(1)解析

解析登录请求post_data中的登录请求数据

cpp 复制代码
//api_login.cc
// / 解析登录信息
int decodeLoginJson(const std::string &str_json, string &email,
                    string &password) {
    bool res;
    Json::Value root;
    Json::Reader jsonReader;
    res = jsonReader.parse(str_json, root);
    if (!res) {
        LOG_ERROR << "parse login json failed ";
        return -1;
    }
 
    if (root["email"].isNull()) {
        LOG_ERROR << "email null";
        return -1;
    }
    email = root["email"].asString();

    //密码
    if (root["password"].isNull()) {
        LOG_ERROR << "password null";
        return -1;
    }
    password = root["password"].asString();

    return 0;
}
(2)验证

验证用户的邮箱和密码是否正确

cpp 复制代码
//api_login.cc
int verifyUserPassword(string &email, string &password) {
    int ret = -1;
    // 只能使用email
    CDBManager *db_manager = CDBManager::getInstance();
    CDBConn *db_conn = db_manager->GetDBConn("chatroom_slave");
    AUTO_REL_DBCONN(db_manager, db_conn);   //析构时自动归还连接
    if(!db_conn) {
        LOG_ERROR << "get db conn failed";
        return -1;
    }

    //根据email查询密码
    string strSql = FormatString("select username, password_hash, salt from users where email='%s'", email.c_str());
    LOG_INFO << "执行:" << strSql;
    CResultSet *result_set = db_conn->ExecuteQuery(strSql.c_str());
    if (result_set && result_set->Next()) { //如果存在则读取密码 
        string username = result_set->GetString("username");
        string db_password_hash = result_set->GetString("password_hash");
        string salt = result_set->GetString("salt");
        MD5 md5(password + salt);  // 计算出新的密码
        string client_password_hash = md5.toString();  //  计算出新的密码
        if(db_password_hash == client_password_hash) {
            LOG_INFO << "username: " << username << " verify ok";
            ret = 0;
        } else {
            LOG_INFO << "username: " << username << " verify failed";
            ret = -1;
        }
    } else {
        ret = -1;
    }

    if(result_set)
        delete result_set;

    return ret;
}
(3)返回

登录成功返回cookie,失败返回错误信息。

cpp 复制代码
//api_login.cc
int ApiUserLogin(std::string &post_data, std::string &resp_data){
    //
    string email;
    string password;

    // json反序列化
      // 解析json
    if (decodeLoginJson(post_data, email, password) < 0) {
        LOG_ERROR << "decodeRegisterJson failed";
        encodeLoginJson(api_error_id::bad_request, "email or password no fill", resp_data);
        return -1;
    }

    //校验邮箱  密码
    int ret = verifyUserPassword(email, password);
    if(ret == 0) {
        // 设置cookie
        ApiSetCookie(email, resp_data);
    } else {
        encodeLoginJson(api_error_id::bad_request, "email password no match", resp_data);
    }
    return ret;
}

2.2聊天模块(websocket_conn.cc)

客户端对服务器的主动发送消息,有可能是 "握手信号" ,有可能非握手信号。非握手信号有:"具体消息" 和 "历史消息的查看"

cpp 复制代码
//websocket_conn.cc
void CWebSocketConn::OnRead(客户端发送来的Websocket数据帧)
{
    if(连接还没建立){
        ->  2.2.1建立websocket连接
    }
    else{
        先解析Websocket数据帧,拿到其中的payload  json字段

        if(json中type字段 == "clientMessages")
            ->    2.2.2处理用户消息
        else if(json中type字段 == "requestRoomHistory")
            ->    2.2.3请求历史消息
        else if(json中type字段 == "clientCreateRoom")
            ->    2.2.4创建聊天室
    }
}
2.2.1建立websocket连接
(1)握手

获取Websocket数据帧中Sec-WebSocket-Key 字段的值,服务器基于它生成一个 Sec-WebSocket-Accept 值,返回给客户端以完成握手。

cpp 复制代码
//websocket_conn.cc
void CWebSocketConn::OnRead(Buffer* buf){

if (!handshake_completed_) {
        // 客户端 服务端 握手
        string request = buf->retrieveAllAsString();
        LOG_INFO << "request" << request;
        size_t key_start = request.find("Sec-WebSocket-Key: ");
        if(key_start != std::string::npos) {
            //说明能找到Sec-WebSocket-Key: 
            key_start += 19;  //19是 Sec-WebSocket-Key:  的长度
            size_t key_end = request.find("\r\n", key_start); // key_start -- key_end = Y90uUZxPYAVrEFJrtYEfCg==

            std::string key = request.substr(key_start, key_end - key_start);
            
            // base 64编码(SHA1(key + 固定str))
            string response = generateWebSocketHandshakeResponse(key);  
            send(response); //握手成功
            handshake_completed_ = true;
            LOG_INFO << "handshake_completed_ ok";
}


...cookie检验过程

}
(2)检验cookie
cpp 复制代码
//websocket_conn.cc

void CWebSocketConn::OnRead(Buffer* buf){

...握手过程


string Cookie = headers_["Cookie"];
            LOG_INFO << "Cookie: " << Cookie;
            string sid;
            if(!Cookie.empty()) {
                sid = extractSid(Cookie); 
            }
            LOG_INFO << "sid: " << sid;
            
            // 根据cookie-sid ,获取 用户名 用户id Email
            string email;
            if(Cookie.empty() || ApiGetUserInfoByCookie(username_, userid_, email, sid) < 0) {
                string reason;
                if(email.empty()) {
                    reason = "cookie validation failed";
                } else {
                    reason = "db  may be has issue";
                }
                // 校验失败
                sendCloseFrame(1008, reason);
            } else {
                //校验成功
                // 把连接加入 s_user_ws_conn_map    统一管理websocket连接
                LOG_INFO << "cookie validation ok";
                s_mtx_user_ws_conn_map_.lock();
                s_user_ws_conn_map.insert({userid_,shared_from_this()});  // 同样userid连接可能已经存在了
                s_mtx_user_ws_conn_map_.unlock();
                
                //获取用户的聊天室列表
                std::vector<Room> &room_list = GetRoomList(); 
                for(int i = 0; i < room_list.size(); i++) {
                    rooms_map_.insert({room_list[i].room_id, room_list[i]});
                }
                 // 发送"hello"给 客户端  
                sendHelloMessage();
            }

}
(3)发送"hello"信息

初始化客户端的界面,展示用户信息和聊天室的历史消息。

数据流:从Redis获取消息,json序列化为payload,再封装成websocket格式的frame,一个WebSocket 数据帧,发送。

cpp 复制代码
//websocket_conn.cc
//获取用户信息和用户加入的聊天室的历史消息存入结构体,根据结构体字段值,构造json发送给客户端
int CWebSocketConn::sendHelloMessage()
{
    Json::Value root;
    root["type"] = "hello";
    Json::Value payload;
    Json::Value me;
    me["id"] = userid_;
    me["username"] = username_;
    payload["me"] = me;

    Json::Value rooms;
    int it_index = 0;

    //每个用户都有自己的rooms_map_
    // 遍历加入的房间,获取每个房间的历史消息
    for (auto it = rooms_map_.begin(); it != rooms_map_.end(); ++it)
    {
        Room &room_item = it->second;
        string  last_message_id;  
        MessageBatch  message_batch;
到Redis获取房间的消息,存入message_batch结构中
        ApiGetRoomHistory(room_item, message_batch);   
        LOG_INFO << "room: " << room_item.room_name << ", history_last_message_id:" << it->second.history_last_message_id;
        Json::Value  room;  
        room["id"] = room_item.room_id;      //聊天室主题名称
        room["name"] = room_item.room_name;      //聊天室主题名称 先设置成一样的
        room["hasMoreMessages"] = message_batch.has_more;
        Json::Value  messages; 
        for(int j = 0; j < message_batch.messages.size(); j++) {
            Json::Value  message;
            Json::Value user;
            message["id"] = message_batch.messages[j].id;
            message["content"] = message_batch.messages[j].content;   
            user["id"] = userid_;
            user["username"] = username_;
            message["user"] = user;
            message["timestamp"] = (Json::UInt64)message_batch.messages[j].timestamp;
            messages[j] = message;
        }
        if(message_batch.messages.size() > 0)
            room["messages"] = messages;
        else 
            room["messages"] = Json::arrayValue;  //不能为NULL,否则前端报异常
        rooms[it_index] = room;
        it_index++;
    }
    payload["rooms"] = rooms;
    root["payload"] = payload;
    Json::FastWriter writer;
    string str_json = writer.write(root);

    
    // 打印 JSON 字符串
    LOG_INFO << "Serialized JSON: " << str_json;
    string hello = buildWebSocketFrame(str_json);
    send(hello);
}
2.2.2处理聊天消息
(1)房间管理

实现一个PubSubService类,全局房间订阅服务,提供获取所有房间ID,增加/删除房间,加入/退出房间,在房间内发布消息 六个接口。

cpp 复制代码
//pub_sub_service.h
//单个房间信息
class RoomTopic 
{
public:
    RoomTopic(const string &room_id, const string &room_topic, uint32_t creator_id) {
        room_id_ = room_id;
        room_topic_ = room_topic;
        creator_id_ = creator_id;
    }
    ~RoomTopic() {
        user_ids_.clear();
    }
    void AddSubscriber(uint32_t userid) {
        user_ids_.insert(userid);
    }
    void DeleteSubscriber(uint32_t userid) {
        user_ids_.erase(userid);
    }
    std::unordered_set<uint32_t> &getSubscribers() {
        return user_ids_;
    }
 private:
    string room_id_;
    string room_topic_;
    int creator_id_;
    std::unordered_set<uint32_t> user_ids_;
};

using RoomTopicPtr = std::shared_ptr<RoomTopic>;
using PubSubCallback = std::function<void(const std::unordered_set<uint32_t> user_ids)>;

//全局的发布订阅服务
class PubSubService
{
public:
    //单例模式
    static PubSubService &GetInstance() {
        static PubSubService instance;
        return instance;
    }
    PubSubService() {}
    ~PubSubService(){}
    bool AddRoomTopic(const string &room_id, const string &room_topic, int creator_id) {
        std::lock_guard<std::mutex> lck(room_topic_map_mutex_);

        if (room_topic_map_.find(room_id) != room_topic_map_.end()) {
            return false;
        }
        RoomTopicPtr room_topic_ptr = std::make_shared<RoomTopic>(room_id, room_topic, creator_id);
        room_topic_map_[room_id] = room_topic_ptr;
        return true;
    }
    void DeleteRoomTopic(const string &room_id) {
        std::lock_guard<std::mutex> lck(room_topic_map_mutex_);
        if (room_topic_map_.find(room_id) != room_topic_map_.end()) {
            return;
        }
        room_topic_map_.erase(room_id);
    }


在websocket_conn.cc里,建立连接成功后,不仅会把userid加入s_user_ws_conn_map,还会用下面的函数订阅GetRoomList()里面默认存在的五个房间
    bool AddSubscriber(const string &room_id, uint32_t userid) {
        std::lock_guard<std::mutex> lck(room_topic_map_mutex_);
        if (room_topic_map_.find(room_id) == room_topic_map_.end()) {
            return false;
        }
        room_topic_map_[room_id]->AddSubscriber(userid);
        return true;
    }
    void DeleteSubscriber(const string &room_id, uint32_t userid) {
        std::lock_guard<std::mutex> lck(room_topic_map_mutex_);
        if (room_topic_map_.find(room_id) == room_topic_map_.end()) {
            return;
        }
        room_topic_map_[room_id]->DeleteSubscriber(userid);
    }
    void PublishMessage(const string &room_id,  PubSubCallback callback) {
        std::unordered_set<uint32_t> user_ids;
        {
            std::lock_guard<std::mutex> lck(room_topic_map_mutex_);
            if (room_topic_map_.find(room_id) == room_topic_map_.end()) {
                return;
            }
            user_ids = room_topic_map_[room_id]->getSubscribers();  //获取当前房间的所有订阅者
        }
        
        callback(user_ids);   //发送给所有订阅者
    }
    static std::vector<Room> &GetRoomList();  //获取当前的房间列表
private:
    std::unordered_map<string, RoomTopicPtr> room_topic_map_;
    std::mutex room_topic_map_mutex_;
};
(2)消息发送

客户端发送消息到服务端,服务端要把消息解析出来(按照websocket格式解析出frame,再通过json反序列化获取payload_data),写到redis里面,并且重新封装消息后,通过PublishMessage发送到房间里。具体的流程是通过回调函数,将消息发给每一个订阅了 房间的用户。

cpp 复制代码
//websocket_conn.cc
int CWebSocketConn::handleClientMessages(Json::Value &root)
{
    // Json反序列化把json消息解析出来
    string room_id;
    if(root["payload"].isNull()) {
        return -1;
    }
    Json::Value payload = root["payload"];
    if(payload["roomId"].isNull()) {
        return -1;
    }
    room_id = payload["roomId"].asString();
    if(payload["messages"].isNull()) {
        return -1;
    }
    Json::Value arrayObj = payload["messages"];
    if(arrayObj.isNull()) {
        return -1;
    }
    std::vector<Message> msgs;  //消息具体内容 和相关发送信息
    uint64_t timestamp = getCurrentTimestamp();
    for(int i = 0; i < arrayObj.size(); i++) {
        Json::Value message = arrayObj[i];
        Message msg;
        msg.content = message["content"].asString();
        msg.timestamp = timestamp;
        msg.user_id = userid_;
        msg.username = username_;
        msgs.push_back(msg);
    }

    //持久化    写到redis里面
    ApiStoreMessage(room_id, msgs);
    //重新组织一个json
    root = Json::Value();
    payload = Json::Value();
    root["type"] = "serverMessages";
    payload["roomId"]  = room_id;
    Json::Value messages;
    
    for(int j = 0; j < msgs.size(); j++) {
        Json::Value  message;
        Json::Value user;
        message["id"] = msgs[j].id;
        message["content"] = msgs[j].content;   
        user["id"] = userid_;
        user["username"] = username_;
        message["user"] = user;
        message["timestamp"] = (Json::UInt64)msgs[j].timestamp;
        messages[j] = message;
    }
    if(msgs.size() > 0)
        payload["messages"] = messages;
    else
        payload["messages"] = Json::arrayValue;
    root["payload"] = payload;
    Json::FastWriter writer;
    string str_json = writer.write(root);
    //json包装成Websocket帧
    std::string response = buildWebSocketFrame(str_json);

    //回调函数具体执行    将消息发送给房间的所有订阅者
     auto callback = [&response, &room_id, this](const std::unordered_set<uint32_t> user_ids) {
        
        for (uint32_t userId: user_ids)
        {
             CHttpConnPtr ws_conn_ptr = nullptr;
             {
                std::lock_guard<std::mutex> ulock(s_mtx_user_ws_conn_map_); //自动释放
                ws_conn_ptr =  s_user_ws_conn_map[userId];//获取每个用户ID对应的连接
             }
             if(ws_conn_ptr) {
                ws_conn_ptr->send(response);
//如果找到用户的 WebSocket 连接,则调用 send 方法,将消息发送给该用户。
             } else
             {
                LOG_WARN << "can't find userid: " << userId;
             }
            /* code */
        }
        
     };

    // 广播给所有人    走回调函数
    PubSubService::GetInstance().PublishMessage(room_id, callback);
    return 0;
}
2.2.3请求历史消息

返回客户端要求的历史消息条数,count是从客户端发送的 JSON 数据中提取的,如果房间中剩余的消息少于 count,则返回所有剩余的消息。如果房间中剩余的消息多于 count,则只返回 count 条消息。客户端也可以通过动态设置count的值实现分页加载。

cpp 复制代码
//websocket_conn.cc
//如果hasMoreMessages为ture说明还有更多历史消息可以继续获取,如果hasMoreMessages为false,则没有更多历史消息可以获取。
int CWebSocketConn::handleRequestRoomHistory(Json::Value &root) {
    std::string roomId = root["payload"]["roomId"].asString();
    std::string firstMessageId = root["payload"]["firstMessageId"].asString();
    int count = root["payload"]["count"].asInt();


    // 从数据库获取历史消息
    string  last_message_id;
    MessageBatch  message_batch;
    Room &room_item = rooms_map_[roomId];
    room_item.history_last_message_id = firstMessageId;
    // 获取房间的消息
    ApiGetRoomHistory(room_item, message_batch, count);  
    
    root = Json::Value(); //重新置空
    // 构建响应
    root["type"] = "serverRoomHistory";
    Json::Value payload;
    payload["roomId"] = room_item.room_id;      //聊天室id
    payload["name"] = room_item.room_name;  //聊天室名称
    payload["hasMoreMessages"] = message_batch.has_more; //是否还有更多消息
    
    // 构建消息数组
    Json::Value messages(Json::arrayValue);
    for (const auto& msg : message_batch.messages) {
        Json::Value message;
        Json::Value user;
        
        message["id"] = msg.id;
        message["content"] = msg.content;
        user["id"] = (Json::Int64)msg.user_id;
        user["username"] = msg.username;
        message["user"] = user;
        message["timestamp"] = (Json::UInt64)msg.timestamp;
        
        messages.append(message);
    }
    if(message_batch.messages.size() > 0)
        payload["messages"] = messages;
    else 
        payload["messages"] = Json::arrayValue;  //不能为NULL,否则前端报异常

    root["payload"] = payload;
    std::string response = buildWebSocketFrame(root.toStyledString());
    send(response);
    return 0;
}
2.2.4创建聊天室

用户可以创建聊天室,所以我们需要把原先写死的room_list,改成一个数据库中的数据表,在数据库中存储起来,所以对应的也就会有很多聊天室相关的字段:room_id(UUID生成),room_name,room_creater。之后在服务器重启的时候,就会从mysql中读出所有的room_list,重新创建房间。

当用户创建聊天室,服务端收到相关的websocket请求,把json解析出来后,分配房间id,通过ApiCreateRoom() 写入到mysql数据库当中(不是redis),再通过PubSubService类的两个函数把新建的聊天室加入到 room_list(负责存储房间信息,给用户展示)和room_topic_map(负责管理订阅关系,发布消息),随后让每一个用户都强制订阅这个聊天室,并且给他们send有新聊天室创建的消息。

(1)业务处理
cpp 复制代码
//websocket_conn.cc
//请求格式: {"type":"clientCreateRoom","payload":{"roomName":"dpdk教程"}} 
//响应格式: {"type":"serverCreateRoom","payload":{"roomId":"3bb1b0b6-e91c-11ef-ba07-bd8c0260908d", "roomName":"dpdk教程"}}
int CWebSocketConn::handleClientCreateRoom(Json::Value &root)
{
     LOG_INFO << "handleClientCreateRoom into";
    // 把消息解析出来
    string roomId;
    string roomName;
    Json::Value payload = root["payload"];

    if(payload.isNull()) {
        LOG_WARN << "payload is null";
        return -1;
    }
    // 解析json  解析聊天室的名字 
    if(payload["roomName"].isNull()) {
        LOG_WARN << "roomName is null";
        return -1;
    }
    roomName = payload["roomName"].asString();
    // 分配房间id
    roomId = generateUUID();
    LOG_INFO << "handleClientCreateRoom, roomName: " << roomName << ", roomId: " << roomId;
    

    //存储到数据库
    std::string error_msg;
    bool ret = ApiCreateRoom(roomId, roomName, userid_, error_msg);
    if(!ret ) {
        LOG_ERROR << "ApiCreateRoom failed: " << error_msg;
        return -1;
    }

    PubSubService::GetInstance().AddRoomTopic(roomId, roomName, userid_);
    // 把新建的聊天室加入到 room_list
    Room room;
    room.room_id = roomId;
    room.room_name = roomName;
    room.create_time = getCurrentTimestamp();
    room.creator_id = userid_;
    PubSubService::AddRoom(room);

    //每个人都强制订阅这个聊天室
    {
        std::lock_guard<std::mutex> lock(s_mtx_user_ws_conn_map_);
        rooms_map_.insert({roomId, room});
        for(auto it = s_user_ws_conn_map.begin(); it != s_user_ws_conn_map.end(); ++it) {
            //房间id, 用户id 订阅
            LOG_INFO << "AddSubscriber: " << roomId << ", userid: " << it->first;
            PubSubService::GetInstance().AddSubscriber(roomId, it->first);
        }
    }

    //发送消息给所有人,告诉有新的聊天室创建了
    //先序列化消息
    // Json::Value root;
    root = Json::Value(); //重新置空
    // Json::Value payload;
    payload = Json::Value(); //重新置空
    root["type"] = "serverCreateRoom";
    payload["roomId"] = roomId;
    payload["roomName"] = roomName;
    root["payload"] = payload;
     //json序列化
    Json::FastWriter writer;
    string json_str = writer.write(root);
    LOG_INFO << "serverCreateRoom: " << json_str;
    string response = buildWebSocketFrame(json_str);

    //发送 创建聊天室的 roomId 和 roomName,通知所有订阅者有新的聊天室可用
    auto callback = [&response, &roomId, this](const std::unordered_set<uint32_t> &user_ids) {
        LOG_INFO << "room_id:" << roomId << ", callback " <<  ", user_ids.size(): " << user_ids.size();
        for (uint32_t userId: user_ids) {
            CHttpConnPtr  ws_conn_ptr = nullptr;
            {
                std::lock_guard<std::mutex> ulock(s_mtx_user_ws_conn_map_); //自动释放
                ws_conn_ptr = s_user_ws_conn_map[userId];
            }
            if(ws_conn_ptr) {
                ws_conn_ptr->send(response);
            } else {
                LOG_WARN << "can't find userid: " << userId;
            }
        }
    };


    PubSubService::GetInstance().PublishMessage(roomId, callback);

    return 0;
}
(2)提供数据的api
cpp 复制代码
//api_room.cc
#include "api_room.h"
#include <sstream>
bool ApiCreateRoom(const std::string& room_id, 
                        const std::string& room_name, 
                        int creator_id, 
                        std::string& error_msg)
{
    CDBManager* db_manager = CDBManager::getInstance();
    CDBConn* db_conn = db_manager->GetDBConn("chatroom_master");
    if (!db_conn) {
        error_msg = "无法获取数据库连接";
        return false;
    }
    AUTO_REL_DBCONN(db_manager, db_conn);
    std::stringstream ss;
    ss << "INSERT INTO room_info (room_id, room_name, creator_id) VALUES ('"
       << room_id << "', '"
       << room_name << "', "
       << creator_id << ")";
    
    if (!db_conn->ExecuteUpdate(ss.str().c_str(), true)) {
        error_msg = "创建聊天室失败";
        return false;
    }
    return true;
}

bool ApiGetRoomInfo(const std::string& room_id, 
                         std::string& room_name, 
                         int& creator_id,
                         std::string& create_time,
                         std::string& update_time,
                         std::string& error_msg)
 {
    CDBManager* db_manager = CDBManager::getInstance();
    CDBConn* db_conn = db_manager->GetDBConn("chatroom_slave");
    if (!db_conn) {
        error_msg = "无法获取数据库连接";
        return false;
    }
    AUTO_REL_DBCONN(db_manager, db_conn);

    
    std::stringstream ss;
    ss << "SELECT room_name, creator_id, create_time, update_time "
       << "FROM room_info WHERE room_id='" << room_id << "'";

    CResultSet* result_set = db_conn->ExecuteQuery(ss.str().c_str());
    if (!result_set) {
        error_msg = "查询聊天室信息失败";
        return false;
    }

    if (result_set->Next()) {
        room_name = result_set->GetString("room_name");
        creator_id = result_set->GetInt("creator_id");
        create_time = result_set->GetString("create_time");
        update_time = result_set->GetString("update_time");
        delete result_set;
        return true;
    }
    delete result_set;
    error_msg = "聊天室不存在";
    return false;
 }                        

bool ApiGetAllRooms(std::vector<Room>& rooms, 
                        std::string& error_msg,
                        const std::string& order_by)
{   
    CDBManager* db_manager = CDBManager::getInstance();
    CDBConn* db_conn = db_manager->GetDBConn("chatroom_slave");
    if (!db_conn) {
        error_msg = "无法获取数据库连接";
        return false;
    }
    AUTO_REL_DBCONN(db_manager, db_conn);

    
    std::stringstream ss;
    ss << "SELECT room_id, room_name, creator_id, create_time, update_time "
       << "FROM room_info ORDER BY " << order_by;

    CResultSet* result_set = db_conn->ExecuteQuery(ss.str().c_str());
    if (!result_set) {
        error_msg = "查询聊天室列表失败";
        return false;
    }

     while (result_set->Next()) {
        Room room;
        room.room_id = result_set->GetString("room_id");
        room.room_name = result_set->GetString("room_name");
        room.creator_id = result_set->GetInt("creator_id");
        room.create_time = result_set->GetString("create_time");
        room.update_time = result_set->GetString("update_time");
        rooms.push_back(room);
    }
    delete result_set;

    return true;
}

3 分布式

如果有三台服务器分别部署在北京、上海、深圳,然后这三个地方分别有用户A、B、C订阅了1号聊天室,如果A在里面发消息,如何让不同服务器的B和C看见呢?如果后端需要发布公告在1号聊天室,如何让三台不同服务器上的用户看见呢?

首先所有服务器上有聊天室注册上线的时候,都应该在一个"注册服务发现中心"注册自己的信息,比如来自于哪一台服务器,由 "job"服务从注册服务发现中心里面拉取chatroom列表,发起连接,由"grpc"推送消息到chatroom,然后再推送到web客户端。

而如果是后端消息公告的话,会把消息推送到"logic"模块,然后再push发送到Kafka消息队列中,再由job服务从Kafka队列获取数据。其中logic和job服务都可以是多个并发的。

各模块作用:

logic:业务逻辑核心,用户登录注册管理,消息管理。

Kafka:缓存消息

job:指代一个任务线程

grpc:远程过程调用。比如可以调用比如在北京的服务器上编写好的函数,进而让job完成工作

注册服务发现中心:

(1)告知job,chatroom grpc远程服务器的地址。由chatroomID和服务器IP对应。

(2)告知chatroom,可用的logic grpc远程服务器的地址。例如,用户发送一条消息,chatroom 服务需要把消息交给 logic 服务进行处理和广播

nginx:可以在chatroom和Web客户端之间加上nginx反向代理,实现负载均衡,给Web客户端返回负载最低的服务器。

上线前项目存在的问题

出现的问题1:

1.为什么客户端一发消息或者发起创建房间请求,服务端就段错误

出现的问题2:

2.为什么我的/server/bin文件夹下面没有logic和job可执行文件

面试题

  • 这个项目框架是怎么样的,为什么这么设计?
    • 答:采用了分层分块的框架设计,首先是负责接收和响应客户端的api处理层,有如api_login、api_msg、api_room这样的登录、发送消息、获取房间列表的接口,他们解析HTTP请求,校验参数,然后调用server层处理业务逻辑,最终组装返回HTTP响应。然后是server层,实现具体的业务流程,调用数据访问层获取或者存储数据。最底下是数据访问层,封装了所有对MySQL和Redis的操作,提供接口给服务层调用。这样设计便于维护和扩展。
    • 整个系统如何实现水平扩展?
    • 答:在服务端启用多个实例,监听多个端口。在多个实例前再部署负载均衡器如Nginx,将客户端请求分发给后端的实例。任务处理可以用Kafka消息队列解耦,提升吞吐能力。数据层如Redis可以搭建Redis Cluster水平扩展。
  • 网络框架是如何实现的,有没有考虑过Boost.Asio
    • 答:采用muduo实现高性能TCP通信,支持HTTP和WebSocket协议。Boost.Asio的接口偏底层,没有muduo网络接口封装的好,易于上手。
    • 主 Reactor 和 Sub Reactor 如何分工?连接分发策略是什么?如何避免惊群效应?
    • 答:主 Reactor 负责监听accept新连接,Sub Reactor 负责已建立连接的数据读写和事件分发。连接分发的策略是,一个连接对应一个子Reactor。采用了one loop per thread模型避免惊群效应,每个子Reactor都只管理自己线程内的连接。
    • 如何在 网络框架 中实现连接的空闲超时检测和自动断开?请描述 TimerQueue 的工作原理
    • 答:每个连接维护一个定时器,定期检查连接的活跃状态,如果连接在设定时间内没有任何读写事件,则通过定时器回调自动关闭连接,释放资源。TimerQueue 本质是一个最小堆,按照时间排序,定时触发回调。
    • 当大量客户端并发连接时,如何优化 TCP 参数(如 SO_REUSEPORTTCP_NODELAY)来提升网络性能?
    • 答:SO_REUSEPORT:允许多个进程/线程绑定同一端口,提升多核利用率,减少锁竞争,适合高并发场景。TCP_NODELAY:减少小包延迟,适合实时性要求高的应用
      • 如果是文件传输,又该如何优化tcp参数(协议栈的发送/接收缓冲区,应用层的缓冲区)?
      • 答:增大 协议栈的发送/接收缓冲区,提升传输效率,应用层采用分块/异步读写。
  • HTTP相关
    • 如何做登录验证
    • 答:通过发送来的邮箱获取数据库中的加密后的密码和对应的salt值,把发送来的密码也与salt结合计算后,比对是否相同,若相同生成cookie后续请求带上表明身份。
    • websocket和http有什么区别
    • 答:http的短连接,请求-响应模式。WebSocket是长连接,全双工通信,适合实时推送。
    • 客户端通过 HTTP 请求注册/登录,你是如何在网络框架中处理 HTTP 协议的?如何解析请求头、请求体?如何返回 JSON 响应?
      • 答:网络框架中有专门的 HTTP 解析模块 http_parser,处理HTTP协议。具体的过程是:首先查找 \r\n\r\n,这是 HTTP 请求头和请求体的分隔符。在分隔符之前,第一行为请求行,后续每一行都是请求头 。请求头中Content-Length 字段,表示请求体的长度,从缓冲区中读取对应长度的数据作为请求体。 返回JSON响应:组装json对象,将json对象序列化为字符串,按HTTP格式拼接响应头和响应体,将完整的HTTP响应发送给客户端。
  • RPC相关
    • grpc远程调用起什么作用

    • 答:gRPC 用于 chatroom与 logic和job 模块之间的消息推送、任务分发。提升了服务间通信的性能和开发效率。

    • grpc 目前客户端 -> 服务端 qps多少

    • 答:2w

    • 项目中用同步方式还是异步方式?qps是否有差别

    • 答:异步方式,异步方式下 QPS 明显更高。

    • 为什么选择 gRPC 而不是 RESTful API 在chatroom(comet) 和 Logic 之间通信?gRPC 的优势和潜在问题是什么

    • 答:优势:相比 RESTful API(基于 HTTP/1.1、文本 JSON),gRPC(基于 HTTP/2 和 Protobuf) 延迟更低、带宽占用更小、接口更规范。 潜在问题:调试不如 REST 直观,跨语言支持需生成代码,协议升级需兼容处理。

    • Job 模块通过 gRPC 调用 chatroom 服务推送消息,你是如何设计 proto 文件的?消息体包含哪些字段?如何处理流式推送

    • 答:
      pre:gRPC 是一种高性能的远程过程调用(RPC)框架,必须用 proto 文件来定义通信协议。写好proto文件后,用 protoc 工具自动生成代码,Job 和 chatroom 服务都可以用这些代码来收发消息、调用接口。
      proto文件设计:用 message 关键字定义了消息体的字段,保证Job、chatroom、logic 等模块之间传递的消息结构一致。用 service 关键字定义了 gRPC 服务接口。定义返回结果的结构。

      包含的字段:消息体包含:消息ID、房间ID、发送者ID、内容、时间戳、消息类型 字段
      流式推送:流式推送可用 gRPC 的 server streaming 或 bidirectional streaming,这样服务端可以持续向客户端推送消息。

    • gRPC 调用失败时(如网络抖动、服务宕机),Job 模块如何实现重试机制?是同步重试还是异步重试?如何避免消息重复?

    • 答:通过重试队列和定时任务实现,采用异步重试避免阻塞主流程,每条消息有唯一ID,防止重复推送。

    • gRPC 默认使用 HTTP/2,它相比 HTTP/1.1 在聊天室系统中带来了哪些性能优势?如何监控 gRPC 调用的延迟

    • 答:性能上,HTTP/2 支持多路复用、头部压缩、二进制传输,极大提升了并发性能和带宽利用率,降低了延迟,非常适合高并发、实时推送的聊天室场景。
      监控方面,可通过 gRPC 自带的拦截器、中间件采集调用延迟、QPS、错误率等指标,并接入 Prometheus、Grafana 等监控系统。

  • kafka相关
    • 为何要引入 Kafka?如果不用 Kafka,直接由 Logic 模块调用 Job 模块的 gRPC 接口推送消息,会有什么问题
    • 答:Kafka可以有效缓解消息堆积。不用会有:1、强耦合问题,Logic 和 Job 必须同时在线,服务间强依赖,任何一方故障都会影响消息链路。2、扩展性差:gRPC 点对点推送,难以应对大规模并发和多 Job 实例的负载均衡。
    • 为什么选择 Kafka 而不是 RabbitMQ 或 RocketMQ?
      答:RabbitMQ与RocketMQ 更适合可靠性、实时性强的小消息场景,但吞吐和扩展性不如 Kafka。
    • Logic 模块将消息写入 Kafka,如何保证消息不丢失?
      答:Kafka Producer 设置 acks=all,确保消息被所有副本写入才算成功。
      Kafka集群采用多副本机制,避免单点故障,而且持久化到磁盘。
      Job 消费后手动提交 offset,确保消息被处理后才标记为已消费。
    • Kafka 的 Topic 如何设计?是按房间 ID 分区,还是全局一个 Topic?分区数量如何确定?
      答:
      pre:Topic 就是 Kafka 里的"消息分类频道",所有消息都按 Topic 分类存储和消费,是 Kafka 消息流转的核心概念。
      全局一个 Topic,这个Topic下分很多区,划分是按房间ID分区,这样可以保证同一个房间的消息在同一个区,保证消息顺序
      分区数量设置为机器核数量的倍数。
    • Kafka 消费者组(Consumer Group)是如何实现负载均衡的?
      答:消费者组里有多个消费者实例。Kafka 会将 Topic 的分区分配给组内的消费者,每个分区只会被组内一个消费者消费。这样可以保证消息不会重复消费,同时实现高并发下的横向扩展。
  • redis相关
    • Redis 中使用 String 缓存用户登录信息(cookie → user),如何设置过期时间?如果用户登出,如何主动删除缓存?
      答:设置过期时间:在set命令后面加上EX秒数。
        主动删除缓存:DEL cookie

    • 为什么选择 Redis Stream 而不是 List 或 Pub/Sub 来存储聊天消息?
      答:List 只适合简单的队列,不能高效地做消息回溯和多消费者消费。
        Pub/Sub 只支持实时推送,消息不落盘,订阅者不在线就会丢消息,无法拉历史消息。
        Stream 结合了队列和消息持久化的优点

    • Redis 如何应对缓存穿透、缓存击穿、缓存雪崩?你采取了哪些措施 缓存穿透:对不存在的数据,查询数据库后也缓存一个空值(如 null),并设置较短过期时间,防止同一个 key 被频繁穿透。

      缓存击穿:对热点 key 设置互斥锁(如分布式锁),只有一个线程去查数据库并回填缓存,其他线程等待,避免高并发下数据库被打爆。

      缓存雪崩:不同 key 设置不同的过期时间(加随机数),避免大量 key 同时失效;同时监控 Redis 状态,提前预警。

    • Redis 是单线程的,为什么还能支持高并发?在你的系统中,如何监控 Redis 的性能瓶颈(如 bigkey、慢查询)?
      答:Redis 虽然是单线程处理命令,但采用了高效的 IO 多路复用(epoll),加上所有操作都是内存级别,单线程反而避免了锁竞争,极大提升了并发处理能力。
        检测性能:
      bigkey:定期用 redis-cli --bigkeys 工具扫描,发现大 key 并优化数据结构。
      慢查询:开启 Redis 慢查询日志(slowlog),分析和优化耗时命令。

  • MySQL 相关
    • MySQL 存储用户和房间信息,表结构如何设计?有哪些索引?为什么?
      用户表:id(主键,自增)、username(普通索引,模糊查询)、email(唯一)、password_hash、salt、create_time、update_time
      房间表:id(主键)、room_id(唯一,UUID)、room_name、creator_id(普通索引,便于查找用户创建的房间)、create_time
    • 如何通过数据库连接池优化 MySQL 访问?你使用的是哪种连接池 ?连接池大小如何设置?
      MySQL内部是一个连接分配一个线程的,所以通过连接池建立多个连接,可以达到在MySQL内部多线程并发的效果。而且连接池可以复用连接,减少频繁创建和销毁连接的开销。
      项目中用的是自研的连接池(见 mysql/db_pool.cc、db_pool.h),通过对象池管理 MySQL 连接,池子里维护一个可用连接队列,创建一定数量的MySQL连接放入队列,业务线程从池中获取空闲连接,没有空闲连接则等待,用完后归还连接。自研池 更适合 C++ 项目和高性能场景,代码量小,易于集成和维护。
      如果是CPU密集型,线程数设置为CPU核心数+1,如果是IO密集型,线程数设置为2倍核心数。
    • 当用户量增长到千万级,如何对 MySQL 进行分库分表?你是按用户 ID 还是房间 ID 分片
      有两类表,对于用户相关表,按用户IDhash分片,保证单表数量均衡。对于房间相关表,按房间 ID hash 分片,保证消息和房间分布均匀。
    • 如何保证 MySQL 和 Redis 的数据一致性?是先更新 DB 再删缓存,还是先删缓存再更新 DB?为什么?
      比如修改张三为李四。
      先更新DB再删缓存。尽管这样可能会导致,如果删除缓存失败,会读取到原先缓存中的旧数据张三
      但是如果是先删缓存再更新DB,那么如果删除缓存的张三后,更新DB前服务宕机了,那么用户就会到DB里面查到张三,而且同步到Redis缓存里面为张三,此时服务恢复,DB里面的张三改为了李四,则出现MySQL与Redis数据不一致的现象。
相关推荐
草莓熊Lotso2 小时前
《回溯 C++98:string 核心机制拆解 —— 从拷贝策略到高效 swap》
开发语言·c++
Jiezcode3 小时前
LeetCode 55.跳跃游戏
c++·算法·leetcode·游戏
l1t3 小时前
在duckdb 1.4中编译和使用postgresql协议插件duckdb-pgwire
开发语言·数据库·c++·postgresql·插件·duckdb
scilwb4 小时前
第二周任务:STM32 + 永刚VESC6电调 + N5065电机CAN通信控制
c++·开源·产品
郝学胜_神的一滴4 小时前
深入理解C++完美转发失败的场景
c++
初圣魔门首席弟子5 小时前
c++中this指针使用bug
前端·c++·bug
K 旺仔小馒头5 小时前
《牛刀小试!C++ string类核心接口实战编程题集》
c++·算法
草莓熊Lotso6 小时前
《吃透 C++ vector:从基础使用到核心接口实战指南》
开发语言·c++·算法
2401_8414956413 小时前
【数据结构】红黑树的基本操作
java·数据结构·c++·python·算法·红黑树·二叉搜索树