简介:这份PPT围绕城配物流智能调度展开,面向物流企业调度与运营人员、TMS厂商及关注AI调度落地的读者,梳理传统调度依赖人工经验、订单增长后排线不准、路线易违反限行规定、时间窗难以满足等痛点。内容从典型城配场景切入,提出在满足收货时间窗等约束下,以成本最优、里程最短、时间最少、各项均衡为目标的方案,并列举仓库时间窗口、车辆组、多仓提货、最大行驶时间与距离、重量体积配载、五限信息、通行证、分时段限行、门店停留时间及固定出车成本等二十余项约束。资源还介绍百度物流地图、云计算与智能调度引擎的算法、算力和地图优势,说明SAAS化Console与API集成两种使用方式,并用中国烟草安徽子公司配送项目、汉得HTMS集成案例展示里程降低15%、运输成本降低17%、调度时间降低91%等效果。压缩包共1个pptx文件,约8.31MB,属于文档资料,已有448人学习。
1. 物流智能调度解决方案:从排线靠经验到约束求解
一个日均 800 单、40 台车的城配团队,调度员早上六点半到岗,把订单按区域分成七堆,凭记忆把老客户、大客户、限时单塞进不同车次,改一个地址就要重画一遍白板。单量涨到 1500 单时,这套流程先崩的不是人,是信息:谁也说不清今天为什么这辆车跑了 180 公里而另一辆只跑了 60 公里。物流智能调度解决方案要处理的正是这件事——把「哪台车、按什么顺序、在什么时间窗内、装多少货去送哪些点」变成一个有目标函数、有硬约束、可求解、可复盘的数学模型,而不是一份只能在会议室里讲、落到现场就失效的 PPT 方案。
它的价值前提是约束足够多。少约束的场景,Excel 排序加人工微调就够;一旦同时出现时间窗、车型载重、冷热分区、装卸时长、司机工时、客户指定时段这些条件,人的直觉就会从资产变成负债。常见的落地方式是先做静态排线(T+1 生成次日线路),再叠加动态插单与滚动重调度,最后接上实时位置与签收反馈做闭环。适合读这篇文章的人:正在评估调度系统的技术负责人、要把调度能力嵌进自己 TMS 的后端工程师,以及被「排线跑不动、插单就乱套」折磨过的算法同学。
2. 把物流智能调度问题写成 VRPTW 数据模型:字段、约束与距离矩阵
模型错了,求解器再强也只是更快地给出错误答案。这一步的目标是把业务语言翻译成 VRPTW(带时间窗的车辆路径问题)标准形态:一个车场、若干台不同车型的车、若干带需求量和时间窗的客户点,求一组总成本最低且满足全部硬约束的回路。下面从字段定义、矩阵计算、约束取舍三个角度拆开。
2.1 订单、车辆、车场:三张表定住问题边界
很多项目卡在第一周,是因为订单表和车辆表里根本没有调度需要的字段。调度不是把订单列表排序,它依赖的是每个点的坐标精度、可服务时段、装卸耗时、货物体积重量与温层属性。下面这张对照表是我一般会先跟业务方确认的最小字段集,缺哪一项就在模型里变成猜,猜错一次就是一次现场返工。
| 对象 | 字段 | 说明 | 缺失后果 |
|---|---|---|---|
| 订单/网点 | node_id | 唯一编号,0 号留给车场 | 无法建矩阵 |
| 订单/网点 | lng / lat | 经纬度,建议 GCJ-02 或 WGS-84 统一 | 距离偏差导致绕路 |
| 订单/网点 | demand | 体积或重量,单位与车辆容量一致 | 超载或空跑 |
| 订单/网点 | tw_start / tw_end | 可服务时间窗,分钟数从当日 0 点起算 | 准时率无法约束 |
| 订单/网点 | service_time | 卸货+签收耗时 | 到点时间整体前移,连锁违约 |
| 订单/网点 | priority | 优先级或是否可甩单 | 无法做软约束惩罚 |
| 车辆 | vehicle_id | 车辆编号 | 结果无法下达 |
| 车辆 | capacity | 载重/容积上限 | 载重维度失效 |
| 车辆 | shift / 工时 | 出车时段与最长在途时间 | 司机超时 |
| 车辆 | start_node / end_node | 起点终点,支持多车场 | 只能单车场建模 |
提示:坐标系统一这件事要在入库时做,不要留到求解时再转换。混用两套坐标系,距离矩阵会出现几百米的系统性偏差,短途配送里这个量级足以改变排线结果。
2.2 距离与时长矩阵:Haversine 先跑通,路网再替换
求解器不认经纬度,它只认矩阵:time_matrix[i][j]和distance_matrix[i][j]。起步阶段用 Haversine 直线距离除以平均车速,能在几毫秒内算出上千点的矩阵,足够跑通链路和验证模型;上线前再换成路网引擎的真实时长。下面的代码是常见的本地矩阵构建方式,注意它返回的是整数分钟,求解器不接受浮点。
import math def haversine_km(lng1, lat1, lng2, lat2): """球面直线距离,单位 km;地球半径取 6371""" r = 6371.0 p1, p2 = math.radians(lat1), math.radians(lat2) dp = p2 - p1 dl = math.radians(lng2 - lng1) a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 return 2 * r * math.asin(math.sqrt(a)) def build_matrices(nodes, avg_speed_kmh=22, detour_factor=1.35): """ nodes: [{'node_id':0,'lng':..,'lat':..,'service_time':..}, ...] detour_factor: 直线距离到实际里程的放大系数,城配常用 1.25~1.45 """ matrix = [] for a in nodes: row = [] for b in nodes: km = haversine_km(a["lng"], a["lat"], b["lng"], b["lat"]) * detour_factor minutes = max(1, int(round(km / avg_speed_kmh * 60))) # 整数,向下保底 1 分钟 row.append(minutes) matrix.append(row) # 到点还要加上本点的卸货耗时,服务时长只跟起点有关 time_matrix = [ [matrix[i][j] + nodes[i]["service_time"] for j in range(len(nodes))] for i in range(len(nodes)) ] return matrix, time_matrix逻辑上分三层:haversine_km只算几何距离,不含路况;detour_factor是经验放大系数,用来逼近真实里程与直线距离的比值,城区取 1.25~1.45,跨城取 1.1~1.15;time_matrix把「行驶时长 + 起点服务时长」合并成一个弧成本,这样后续在时间维度上做累加时,到点即完成卸货,语义更干净。参数失真的排查顺序是:先看矩阵对称性(matrix[i][j] == matrix[j][i]应成立),再看对角线是否为服务时长而非 0,最后抽查 5 个已知点对的真实里程做偏差比对,偏差超过 15% 就调detour_factor或换路网引擎。
2.3 硬约束、软约束与惩罚项:哪些必须满足,哪些可以买
把所有条件都当硬约束,模型会直接无解;全部软化,结果又不可用。我的分法是:安全与合同类进硬约束,体验与成本类进软约束并用惩罚值表达,惩罚值的大小需要业务方参与设定,而不是工程师拍脑袋。
| 约束 | 类型 | 建模手段 | 典型惩罚量级 |
|---|---|---|---|
| 载重/容积上限 | 硬 | 容量维度 + 车辆容量 | 不可违反 |
| 可服务时间窗 | 硬(大客户可软) | 累计变量 SetRange | 1000 分/分钟 |
| 司机连续工时 | 硬 | 工时时长维度 | 不可违反 |
| 客户偏好时段 | 软 | 时间窗放宽 + 早到晚到罚分 | 50 分/分钟 |
| 指定司机/熟客关系 | 软 | 禁止同车或强制同车,降低权重 | 500 分/单 |
| 甩单(无法服务) | 软 | 允许丢点的罚分 | 100000 分/单 |
关键点是甩单罚分要足够大,让求解器优先保证履约,但也要有限,否则一个偏远点会让整体线路崩掉。常见做法是甩单罚分设为「单均毛利」的两到三倍,让算法在「多跑 30 公里」和「不送这一单」之间做出有业务含义的选择。
3. 用 OR-Tools 求解带时间窗的车辆路径:最小可跑通代码与参数
模型准备好之后,选求解器就是选路线。商用求解器效果好但授权成本高,自研元启发式可控但要养人。OR-Tools 的 routing 模块是常见起点:它把 VRPTW 抽象成图上的弧成本 + 维度约束,内置了初始解构造和多种邻域搜索策略,几十行代码就能跑出可用解,后续要替换成自研 ALNS 时,模型结构也能平移。
3.1 从需求模型到求解器模型:索引映射与维度注册
求解器内部用 0 到 N-1 的索引表示节点,和业务 node_id 需要做一次映射。下面这段代码完成了索引管理器、时间维度、容量维度和目标函数的注册,可以直接接第 2 章的time_matrix。
from ortools.constraint_solver import routing_enums_pb2, pywrapcp def solve_vrptw(nodes, vehicles, time_matrix, distance_matrix, demands, time_windows, capacity, depot=0, time_limit_s=30): n = len(time_matrix) manager = pywrapcp.RoutingIndexManager(n, len(vehicles), depot) routing = pywrapcp.RoutingModel(manager) # 弧成本:行驶时长 + 起点服务时长 def time_cb(from_index, to_index): f = manager.IndexToNode(from_index) t = manager.IndexToNode(to_index) return time_matrix[f][t] transit = routing.RegisterTransitCallback(time_cb) routing.SetArcCostEvaluatorOfAllVehicles(transit) # 时间维度:slack 是等待时间,容量上限设为一天 routing.AddDimension(transit, 1440, 1440, True, "Time") time_dim = routing.GetDimensionOrDie("Time") for node_id, (open_t, close_t) in enumerate(time_windows): if node_id == depot: continue idx = manager.NodeToIndex(node_id) time_dim.CumulVar(idx).SetRange(int(open_t), int(close_t)) # 容量维度:按车型分别给容量 def demand_cb(from_index): return int(demands[manager.IndexToNode(from_index)]) demand_idx = routing.RegisterUnaryTransitCallback(demand_cb) routing.AddDimensionWithVehicleCapacity( demand_idx, 0, [int(v["capacity"]) for v in vehicles], True, "Capacity") # 允许甩单:罚分足够大,但有限 for node_id in range(n): if node_id == depot: continue routing.AddDisjunction([manager.NodeToIndex(node_id)], 100000) params = pywrapcp.DefaultRoutingSearchParameters() params.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC params.local_search_metaheuristic = ( routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH) params.time_limit.FromSeconds(time_limit_s) params.log_search = True solution = routing.SolveWithParameters(params) return manager, routing, solution几个参数需要单独说明。AddDimension的第二个参数是slack_max,代表允许的最大等待时长,设成 1440 分钟等于「可以在任何点等到关门」,实际业务里通常限到 60~120 分钟,避免出现早上七点出发、等到下午三点才卸货的荒谬结果;fix_start_cumul_to_zero设为 True 表示车辆从 0 时刻计时出发,如果车辆出车时间不同,就要给每辆车的起点设置各自的SetRange。AddDisjunction的单点罚分 100000 是个示例值,换算方式是「甩一单的损失 / 时间维度的单位」,两边量纲要统一,否则目标函数里时间项会被完全压制。
3.2 初始解策略与邻域搜索:三种组合的适用场景
OR-Tools 的效果七成取决于这两组参数:怎么造初始解,以及用什么邻域搜索去改进。初始解质量差不一定致命,但会让搜索在时间预算内跑不完收敛过程。下面这张表是我在不同规模下常用的组合。
| 场景 | first_solution_strategy | local_search_metaheuristic | time_limit | 说明 |
|---|---|---|---|---|
| 单点调试、模型验证 | PATH_CHEAPEST_ARC | AUTOMATIC | 5s | 快,能验证约束是否正确 |
| 城配日常 500 单以内 | SAVINGS | GUIDED_LOCAL_SEARCH | 20~30s | 节省里程思想,贴近人工直觉 |
| 时间窗紧、点多 | PARALLEL_CHEAPEST_INSERTION | GUIDED_LOCAL_SEARCH | 60s | 插入式初始解对时间窗友好 |
| 多点、多车型、允许甩单 | PATH_MOST_CONSTRAINED_ARC | SIMULATED_ANNEALING | 120s | 约束紧的图用最受限优先更容易出可行解 |
| 决策时限严格(秒级) | AUTOMATIC | GREEDY_DESCENT | 2~3s | 接受次优解,保证响应 |
线上服务里我一般不用time_limit超过 60 秒,超时的收益递减非常明显:从 30 秒加到 120 秒通常只能再降 1%~2% 的里程,但接口延迟从可接受变成不可接受。如果一定要更优解,正确做法是夜里离线跑长时限生成次日线路,白天用短时限做增量调整,而不是把在线接口拖到几十秒。
3.3 读结果:车辆、到离时间、载重与违规自检
求解返回的solution是编码后的对象,必须解回业务语义再落库,否则无法验收。下面这段遍历代码同时做了载重累计和时间窗校验,是上线前必过的自检环节。
def dump_solution(manager, routing, solution, nodes, vehicle_ids): time_dim = routing.GetDimensionOrDie("Time") cap_dim = routing.GetDimensionOrDie("Capacity") report = [] for v in range(len(vehicle_ids)): index = routing.Start(v) route, load, total_time = [], 0, 0 while not routing.IsEnd(index): node = manager.IndexToNode(index) tv = solution.Value(time_dim.CumulVar(index)) # 到达时刻,分钟 load = solution.Value(cap_dim.CumulVar(index)) route.append({"node": node, "arrive": tv, "load": load}) index = solution.NextVar(index).Value() total_time = solution.Value(time_dim.CumulVar(index)) # 用节点自身时间窗做一次独立校验,不信任求解器自报 for step in route[1:]: o, c = nodes[step["node"]]["tw_start"], nodes[step["node"]]["tw_end"] if not (o <= step["arrive"] <= c): report.append({"vehicle": vehicle_ids[v], "node": step["node"], "violation": "time_window", "arrive": step["arrive"]}) report.append({"vehicle": vehicle_ids[v], "route": route, "end_time": total_time, "max_load": load}) return report这里有两个容易忽略的细节。一是循环结束时index指向终点,终点的时间要在循环外用solution.Value单独取,否则会丢掉最后一段回程时长。二是不要直接相信求解器的CumulVar就是客户的到点时间——如果约束写错了(例如 Slack 设得过大、时间窗单位混用分钟和秒),求解器会理直气壮给出违反业务时间窗的「可行解」。独立校验一遍时间窗和载重,是投入产出比最高的一道防线。踩过的坑还包括:需求量为 0 的车场节点被错误计入载重;同一辆车在线路中出现两次(变量未做禁止重复访问);终点节点的服务时长被重复累加导致总工时虚高。
4. 动态订单插入与滚动时域重调度:物流智能调度的在线部分
静态排线解决 T+1 的线路生成,真实的物流智能调度系统里,一天里 30%~50% 的订单是当天新增或变更的。这时候全量重跑会带来两个问题:一是耗时,二是结果抖动——司机按旧线路已经走了三公里,突然被通知改道,信任就没了。在线部分要解决的是「在有限时间内,把新订单插进合适位置,同时把线路变化控制在可解释范围内」。
4.1 滚动时域:冻结窗口、重优化窗口与预测窗口
主流做法是把时间轴切成三段。冻结窗口是从当前时刻到未来 15~30 分钟,这段时间内的动作已下达、不可改;重优化窗口是从冻结末端到未来 2~4 小时,允许插入、允许调整顺序;预测窗口更远的部分只做容量预留,不锁定具体顺序。
切分点怎么定,取决于司机的执行惯性和订单确认节奏。冻结窗口太短,司机频繁接到变更通知,现场会开始无视系统;太长,新订单插不进去,履约率掉。我一般用「最长单点行驶时长 × 1.5」作为冻结窗口初始值,跑两周看司机的变更投诉率和插单成功率再调。工程上,每个订单带着status(已下达/规划中/待分配)和locked_until时间戳,重调度时只对status != '已下达'的订单做决策,这一条规则能挡掉大部分抖动问题。
注意:冻结不等于不计算。对冻结段内的订单仍然要计算实际到达时间,如果发现已明显晚于时间窗,应触发预警与人工介入,而不是等系统默默优化其他线路。
4.2 候选车辆召回与插入成本估算
新订单进来后先不要丢给全量模型,而是用空间索引召回一批候选车辆,再逐个算插入增量成本。Redis 的 GEO 结构在这里很顺手:司机位置高频上报,直接按半径检索。
import redis r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True) def find_candidates(lng, lat, radius_km=8, limit=20): """按距离召回在线车辆,返回到新订单点直线距离最近的候选车""" hits = r.geosearch("drivers:online", longitude=lng, latitude=lat, radius=radius_km, unit="km", withdist=True, sort="ASC", count=limit) return [{"driver_id": h[0], "dist_km": h[1]} for h in hits] def best_insert_position(route_nodes, new_node, dist_matrix, time_matrix, time_windows, arrive_times, remaining_capacity, demand): """ route_nodes: 该车当前线路的节点序列(不含终点) arrive_times: 与 route_nodes 等长的到达时刻(分钟) 返回 (增量里程, 插入位置) 或 None """ if remaining_capacity < demand: return None best = None for pos in range(1, len(route_nodes)): # 不能插到起点之前 prev, nxt = route_nodes[pos - 1], route_nodes[pos] delta = (dist_matrix[prev][new_node] + dist_matrix[new_node][nxt] - dist_matrix[prev][nxt]) # 里程增量,可正可负 # 时间可行性:新点必须落在自己的时间窗内,且不影响后续点 arrive_new = arrive_times[pos - 1] + time_matrix[prev][new_node] o, c = time_windows[new_node] if not (o <= arrive_new <= c): continue affected = [n for n in route_nodes[pos:]] # 后续点整体后移 shift = (time_matrix[prev][new_node] + time_matrix[new_node][nxt] - time_matrix[prev][nxt]) ok = True for i, n in enumerate(affected): no, nc = time_windows[n] if not (no <= arrive_times[pos + i] + shift <= nc): ok = False break if ok and (best is None or delta < best[0]): best = (delta, pos) return best这段逻辑的核心是增量成本公式d(prev,new) + d(new,next) - d(prev,next):它衡量的不是新订单离车有多远,而是「把新点塞进去之后,这辆车要多跑多少路」。实际系统里还要叠几项:晚到时间的罚分、装载率变化、跨温层的额外成本、以及司机熟悉度(同一片区优先派给常跑该区的车)。route_nodes与arrive_times必须一一对应,任何一处错位都会让时间可行性判断静默失效——这类 bug 不报错,只是偶尔给出莫名其妙的线路,排查时优先核对这两个数组的长度和下标。
4.3 增量重优化:只动受影响车辆,控制结果抖动
插入成功后,线路往往不是最优的。这时候做全量重跑代价太大,常见做法是局部重优化:把受影响的 2~5 辆车、其线路上的点、以及新增点取出,构成一个小规模子问题,用第 3 章的求解器跑 2~3 秒。子问题规模通常在 50~200 个点之间,求解质量接近全量,耗时可压到秒级。
抖动控制有几条实践规则。一是限制单次重调度中被改变线路的车辆数不超过在线车辆的 20%,超过就放弃本次优化,只做插入。二是给「与旧线路一致」加奖励:在子问题里为每个保持原有前后继关系的点给负成本,让求解器倾向于少拆线路。三是记录每次重调度的变更日志(哪些订单换了车、里程变化、准时率变化),上线初期每天抽查十条,否则算法会以一种很安静的方式劣化。
| 重调度触发条件 | 处理范围 | 建议响应时间 | 说明 |
|---|---|---|---|
| 新增订单 | 候选车 3~5 台 | < 2s | 走插入成本估算即可 |
| 订单取消 | 单台车 | < 1s | 从线路删除并重算后续到点时间 |
| 车辆故障 | 受影响线路 + 邻近 2 台车 | < 10s | 拆分订单,优先保时间窗最紧的点 |
| 大面积延误 | 片区级子问题 | < 30s | 触发滚动时域前移,缩短冻结窗口 |
| 司机临时缺勤 | 该车全部订单 | < 15s | 等同于订单池重新分配 |
5. 调度方案的验证、压测与参数收敛技巧
算法跑通不等于方案可用,最后一段路靠的是验证方法。调度系统有个特点:它的错误不会立刻暴露,而是以「今天有 12 单超时」的形式慢慢浮现,等发现时往往已经积累了几周的坏数据。所以验证要在上线前就做成固定动作,而不是出问题再补。
5.1 指标口径先对齐,再谈优化
优化目标里的「成本」和业务方说的「成本」经常不是一个东西。上线前把口径写进配置表,所有报表和算法打分共用同一套定义,能省掉大量扯皮。
| 指标 | 计算口径 | 常见误用 |
|---|---|---|
| 准时率 | 到达时刻 ≤ 时间窗右界的订单数 / 总订单数 | 用签收时刻代替到达时刻,把卸货慢算成迟到 |
| 单均里程 | 总行驶里程 / 完成订单数 | 里程含回程,导致短途单被误判为低效 |
| 装载率 | 实际载重 / 核定载重(按车次取峰值) | 用平均值,掩盖了首段超载 |
| 甩单率 | 未分配订单数 / 总订单数 | 与「客户取消」混算 |
| 求解耗时 P95 | 从请求到返回的端到端时间 | 只统计求解器内部耗时,忽略矩阵构建 |
提示:把「矩阵构建耗时」单独打点。1000 个点用 Haversine 建矩阵约几十毫秒,换成路网 API 可能变成十几秒,很多人以为求解器变慢了,实际瓶颈在数据准备阶段。
5.2 影子模式与历史回放压测
上线前最有效的验证方式有两个,成本都不高。影子模式是让调度系统与现有流程并行跑两周:真实下达仍按人工方案,系统同时算一份方案并落库,每天对比两者的单均里程、准时率、车辆数差异。差异在 15% 以内属于正常,说明模型口径和人工直觉在同一量级;差异超过 30% 就要先怀疑数据(坐标漂移、时间窗录入错误)而不是算法。
回放压测是把过去一个月的历史订单按时间顺序喂进系统,模拟动态插单的真实节奏,重点看三件事:P95 求解耗时是否稳定(不能随时间推移线性增长,那说明内存里有未释放的累积结构)、插入成功率、以及连续回放五天后的结果一致性。一致性差通常是随机种子或并行求解引入的,需要固定random_seed并记录每次求解的参数快照,否则同一个输入两次给出不同结果,业务方无法信任。
5.3 参数扫参与收敛:把 time_limit 花在刀刃上
最后是一个具体技巧:不要盲目调大时间上限,而是先定位瓶颈来自哪一类约束。做法是固定输入,只放开一类约束做对照实验——先解开时间窗,看目标值改善多少;再解开容量;最后解开甩单罚分。改善幅度最大的那一类,就是当前模型的紧约束,优化方向应该放在数据质量或业务规则上,而不是死磕求解器参数。
# 参数扫描:固定输入,交替放宽单类约束,观察目标值变化 def sweep(base_data, configs, time_limit=20): results = [] for name, cfg in configs.items(): data = dict(base_data) if cfg.get("relax_time"): # 时间窗全部放宽到全天,只保留容量约束 data["time_windows"] = [(0, 1440)] * len(base_data["time_windows"]) if cfg.get("relax_capacity"): for v in data["vehicles"]: v["capacity"] = 10 ** 6 if cfg.get("no_drop"): # 禁止甩单:把罚分提到极大,观察可行解是否还存在 data["drop_penalty"] = 10 ** 9 _, _, sol = solve_vrptw(**{**data, "time_limit_s": time_limit}) results.append({"config": name, "objective": sol.ObjectiveValue() if sol else None, "dropped": sum(1 for n in range(len(data["time_matrix"])) if n != 0 and sol and sol.Value( data["routing"].NextVar(n)) == n)}) return results扫参的判读逻辑很清楚:如果放宽时间窗后目标值大幅下降,说明时间窗是主要矛盾,应该去和业务方谈时间窗颗粒度(比如把「上午 9 点到 11 点」细化成「9:00–9:40」反而更难解,但会更准时);如果放宽容量后变化明显,说明车型配置与实际货量不匹配,加车比调算法划算。还有一个容易被忽略的收敛技巧:把前一天求解得到的最优线路作为今天的初始解喂进去(设置ReadAssignmentFromRoutes),在订单结构稳定的场景下,收敛速度和结果质量都会更好,代价是当订单分布发生突变时需要清空这个初始解,否则会陷在昨天的结构里出不来。判断突变的简单办法是比对当日订单的地址分布与前三天的重合度,重合度低于 60% 就放弃热启动。
本文还有配套的精品资源,点击获取