3. 服务实例主备切换

服务实例主备切换 学习卡片

任务目标

实现SOME/IP服务实例的主备冗余:主实例正常时处理客户端请求;当主实例故障(进程退出或心跳超时)后,备用实例自动接管服务。客户端通过相同的Service ID/Instance ID访问服务,切换过程对客户端无感知。

核心设计

采用共享状态 + 故障检测的最小可复现方案:主备实例共享同一服务状态存储(本案例用文件模拟),通过租约(Lease)机制决定当前主实例。主实例周期性续租;备用实例检测到租约过期后立即抢占成为新主实例。客户端始终向同一服务地址发送请求,由当前持有租约的实例响应。

实现代码


import time
import os
import threading
import json
from http.server import BaseHTTPRequestHandler, HTTPServer

LEASE_FILE = "/tmp/someip_lease.json"
LEASE_TIMEOUT = 3.0          # 租约超时时间(秒)
RENEW_INTERVAL = 1.0         # 主实例续租周期(秒)
CHECK_INTERVAL = 0.5         # 备用实例检测周期(秒)
STATE_FILE = "/tmp/someip_state.json"

class ServiceState:
    """共享服务状态(主备通过文件共享)"""
    @staticmethod
    def read():
        try:
            with open(STATE_FILE) as f:
                return json.load(f)
        except (FileNotFoundError, json.JSONDecodeError):
            return {"counter": 0}

    @staticmethod
    def write(data):
        with open(STATE_FILE, "w") as f:
            json.dump(data, f)

def acquire_lease(instance_id):
    """尝试获取租约,成功返回True"""
    now = time.time()
    try:
        with open(LEASE_FILE) as f:
            lease = json.load(f)
        if lease["expires_at"] > now and lease["owner"] != instance_id:
            return False  # 其他实例仍持有有效租约
    except (FileNotFoundError, json.JSONDecodeError):
        pass
    # 写入自己的租约
    with open(LEASE_FILE, "w") as f:
        json.dump({"owner": instance_id, "expires_at": now + LEASE_TIMEOUT}, f)
    return True

def renew_lease(instance_id):
    """主实例续租"""
    with open(LEASE_FILE, "w") as f:
        json.dump({"owner": instance_id, "expires_at": time.time() + LEASE_TIMEOUT}, f)

def is_primary(instance_id):
    """检查当前实例是否为主实例"""
    try:
        with open(LEASE_FILE) as f:
            lease = json.load(f)
        return lease["owner"] == instance_id and lease["expires_at"] > time.time()
    except (FileNotFoundError, json.JSONDecodeError):
        return False

class RequestHandler(BaseHTTPRequestHandler):
    """模拟SOME/IP服务请求处理(HTTP封装便于验证)"""
    instance_id = None

    def do_GET(self):
        if not is_primary(self.instance_id):
            self.send_response(503)
            self.end_headers()
            self.wfile.write(b"NOT_PRIMARY")
            return
        # 处理业务:计数器自增
        state = ServiceState.read()
        state["counter"] += 1
        ServiceState.write(state)
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(json.dumps(state).encode())

    def log_message(self, format, *args):
        print(f"[{self.instance_id}] {format % args}")

def run_server(instance_id, port):
    """启动服务实例"""
    RequestHandler.instance_id = instance_id
    server = HTTPServer(("127.0.0.1", port), RequestHandler)
    print(f"[{instance_id}] listening on port {port}")
    server.serve_forever()

def main(instance_id, port):
    # 启动HTTP服务线程
    server_thread = threading.Thread(target=run_server, args=(instance_id, port), daemon=True)
    server_thread.start()

    # 主备逻辑
    while True:
        if acquire_lease(instance_id):
            # 当前是主实例:周期性续租
            print(f"[{instance_id}] became PRIMARY")
            while is_primary(instance_id):
                renew_lease(instance_id)
                time.sleep(RENEW_INTERVAL)
            print(f"[{instance_id}] lost primary role")
        else:
            # 备用实例:等待租约过期后抢占
            time.sleep(CHECK_INTERVAL)

if __name__ == "__main__":
    import sys
    inst = sys.argv[1]   # 实例ID,如 "A" 或 "B"
    port = int(sys.argv[2])
    main(inst, port)

运行与验证

依赖:Python 3.6+,无第三方库。

启动两个实例:


# 终端1:启动实例A(端口8001)
python3 someip_redundancy.py A 8001

# 终端2:启动实例B(端口8002)
python3 someip_redundancy.py B 8002

验证步骤:

  1. 观察两个终端日志,确认只有一个实例输出 became PRIMARY(通常先启动的A成为主实例)。
  2. 向主实例发送请求,确认返回200且计数器递增:

curl http://127.0.0.1:8001/   # 预期返回 {"counter": 1}
curl http://127.0.0.1:8001/   # 预期返回 {"counter": 2}
  1. 向备用实例发送请求,预期返回503(NOT_PRIMARY):

curl http://127.0.0.1:8002/   # 预期返回 503 NOT_PRIMARY
  1. 模拟主实例故障:在终端1按 Ctrl+C 终止实例A。
  2. 等待约3秒(租约超时),观察终端2日志出现 became PRIMARY。
  3. 再次向原备用实例(现主实例B)发送请求,确认返回200且计数器从上次值继续递增:

curl http://127.0.0.1:8002/   # 预期返回 {"counter": 3}

通过条件:实例A故障后,实例B在租约超时(≤3秒)内自动接管;客户端改用端口8002后,服务状态(计数器值)连续无丢失,业务无中断感知。

关键说明

  • 租约文件与状态文件均存储在/tmp,实际部署应替换为共享存储或分布式协调服务(如etcd)。
  • 客户端感知切换的关键是服务发现:本案例通过切换请求端口模拟,真实SOME/IP场景中客户端通过SD(Service Discovery)重新发现新主实例的端点。
  • 本案例使用HTTP封装SOME/IP请求仅为便于验证;核心主备切换逻辑(租约抢占)与具体通信协议无关,可直接迁移至SOME/IP实现。