☰
SQLite单机方案如何支撑同城物流派送系统:表结构、调度与离线容错
2026/10/9 14:18:37 网站建设 项目流程

简介:这是一份面向Android初学者与移动应用开发者的同城物流派送系统完整项目源码,围绕城市内即时配送场景,解决订单发起、司机抢单、货物送达等全流程管理问题,适合作为课程设计、毕业设计或SQLite实战练手参考。资源包共275个文件,约53.42MB,以94个png界面素材、86个class编译文件、39个xml布局、31个java源码为主,另含9个jar依赖、5个so库及apk安装包,覆盖从界面资源到业务逻辑的完整工程结构。项目重点演示SQLite数据库的增删改查、账号注册登录与密码加密、自定义适配器绑定订单与司机列表,以及抢单派送状态流转、GPS定位与地图路线规划等核心环节。目前已有323人学习下载,读者可借此理解Android数据存储、UI适配与订单流程控制的落地写法,并对照源码梳理模块划分与排错思路。

1. 同城物流派送系统拆解:一个 SQLite 单机方案能扛住多少单

同城物流派送这个场景,很多人第一反应是上云、上微服务、上消息队列。但我最近拆到的一个资源包,走的是完全相反的路线:整套系统用 SQLite 做本地存储,单机跑完订单录入、骑手分配、路径排序、状态回传全流程。刚看到时我也犯嘀咕,SQLite 这种嵌入式数据库,并发写一上来不就锁死了吗?但把代码翻完、把表结构拉出来之后,我改主意了——对于日均几百到两三千单的中小配送团队,或者需要离线运行的调度终端,这套方案反而比一堆中间件更省心。

它解决的核心问题很具体:订单进来之后怎么快速落库、怎么按区域和时效把单子分给骑手、骑手端怎么在弱网甚至断网情况下继续更新状态、数据回传后怎么合并。适合谁?适合做同城跑腿、社区团购配送、校园外卖调度这类业务的后端开发,也适合想学调度算法但不想被分布式环境劝退的人。SQLite 在这里不是妥协,是刻意选型,后面我会把理由和边界一条条摆出来。

2. 为什么用 SQLite 做调度底座:选型逻辑与表结构设计

2.1 单机嵌入式数据库在配送场景的真实优势

先说清楚一个前提:同城物流派送的瓶颈从来不在数据库吞吐,而在调度决策和骑手状态同步。一个中等规模的同城配送团队,高峰期每分钟新增订单可能就几十条,状态更新几百次。这个量级用 SQLite 的 WAL 模式完全吃得下。WAL 模式下读写不互相阻塞,写操作是追加到日志文件,读操作走内存映射,实测在普通 SSD 上单表每秒几千次写入没有明显抖动。

更关键的是部署成本。SQLite 不需要独立进程、不需要网络连接、不需要账号权限体系,整个数据库就是一个文件。这意味着调度服务可以打包成一个可执行文件扔到任意一台机器上跑,也可以嵌到骑手端的 App 里做本地缓存。我见过太多团队为了“以后可能扩容”提前上了 PostgreSQL 加 Redis,结果运维复杂度翻了三倍,业务量根本没到那个份上。这个资源包的选型思路就是:先把单机跑通,等真到了写瓶颈再迁移,而 SQLite 的表结构设计得足够规范,迁移到 MySQL 或 PostgreSQL 时改方言的工作量很小。

还有一个容易被忽略的点:离线可用。同城配送的调度中心不一定有稳定的机房环境,有些团队直接在门店放一台小主机就跑起来了。SQLite 的文件级备份和恢复极其简单,复制文件就是全量备份,配合 WAL 日志可以做时间点恢复。这种“黑匣子”式的简单性,在出故障的时候就是后悔药。

2.2 订单表、骑手表、轨迹表的核心字段与索引

资源包里的表结构不算多,但每个字段都有明确用途。我把核心三张表的建表语句整理出来,顺便说明每个字段为什么这么设。

-- 订单表:记录每一单的完整生命周期 CREATE TABLE orders ( order_id TEXT PRIMARY KEY, -- 业务单号,不用自增,方便多端生成 merchant_id TEXT NOT NULL, -- 商户标识 pickup_lat REAL NOT NULL, -- 取货点纬度 pickup_lng REAL NOT NULL, -- 取货点经度 dropoff_lat REAL NOT NULL, -- 送达点纬度 dropoff_lng REAL NOT NULL, -- 送达点经度 status INTEGER DEFAULT 0, -- 0待分配 1已分配 2取货中 3配送中 4已完成 5已取消 rider_id TEXT, -- 分配的骑手,未分配时为空 created_at INTEGER NOT NULL, -- Unix 时间戳,秒级 assigned_at INTEGER, -- 分配时间 finished_at INTEGER, -- 完成时间 priority INTEGER DEFAULT 0 -- 优先级,越大越优先 ); -- 骑手表:记录骑手当前状态和负载 CREATE TABLE riders ( rider_id TEXT PRIMARY KEY, name TEXT, status INTEGER DEFAULT 0, -- 0离线 1空闲 2忙碌 current_lat REAL, current_lng REAL, max_orders INTEGER DEFAULT 5, -- 同时最多接单数 active_count INTEGER DEFAULT 0, -- 当前在手单量 updated_at INTEGER ); -- 轨迹表:骑手位置上报,用于调度和回放 CREATE TABLE rider_tracks ( id INTEGER PRIMARY KEY AUTOINCREMENT, rider_id TEXT NOT NULL, lat REAL NOT NULL, lng REAL NOT NULL, reported_at INTEGER NOT NULL ); -- 关键索引:按状态和区域查订单,按骑手查轨迹 CREATE INDEX idx_orders_status ON orders(status); CREATE INDEX idx_orders_rider ON orders(rider_id); CREATE INDEX idx_tracks_rider_time ON rider_tracks(rider_id, reported_at);

逻辑说明:订单表用 TEXT 主键而不是自增整数,是因为同城配送经常需要多端生成单号,比如商户端、平台端、骑手端都可能创建订单,用业务单号做主键可以避免合并时的冲突。status 用整数枚举而不是字符串,查询和索引效率更高。priority 字段是给调度算法留的扩展点,比如加急单可以设高优先级。

参数说明:max_orders 控制骑手同时接单上限,这个值直接影响配送时效和骑手体验,后面调度章节会细说。active_count 是冗余字段,每次分配或完成订单时同步更新,避免每次调度都去 count 订单表。rider_tracks 表只增不改,定期归档旧数据即可。

2.3 从建库到写入第一条订单的完整操作

拿到资源包后,第一步是把数据库初始化起来。我一般会写一个初始化脚本,把建表、建索引、设置 WAL 模式一次做完。

# 初始化数据库,开启 WAL 模式提升并发读写能力 sqlite3 dispatch.db <<'EOF' PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA foreign_keys=ON; .read schema.sql EOF

逻辑说明:journal_mode=WAL 是必须的,默认的 DELETE 模式在写的时候会锁全库,读操作全部阻塞。synchronous=NORMAL 在 WAL 模式下是安全且性能较好的折中,如果对数据安全性要求极高可以设 FULL,但写入延迟会明显上升。foreign_keys=ON 默认是关闭的,需要显式打开。

参数说明:schema.sql 就是上面那三张表的建表语句,放在同目录下。执行完之后用.tables命令确认表都建好了。接着插入一条测试订单:

INSERT INTO orders (order_id, merchant_id, pickup_lat, pickup_lng, dropoff_lat, dropoff_lng, status, created_at, priority) VALUES ('ORD20250101001', 'M001', 30.2741, 120.1551, 30.2800, 120.1600, 0, strftime('%s','now'), 1);

逻辑说明:created_at 用 strftime 取当前 Unix 时间戳,保证和后续调度逻辑的时间基准一致。status 设为 0 表示待分配,priority 设为 1 表示普通优先级。插入之后可以用SELECT * FROM orders WHERE status=0;确认待分配订单能正常查出。

3. 派单调度核心逻辑:区域分单与骑手负载均衡

3.1 基于网格的区域划分与订单归属判断

同城配送最怕的就是乱派单,骑手从城东跑到城西取一单,油费和时间全搭进去。资源包里用了一个很朴素的网格划分法:把城市按经纬度切成固定大小的格子,每个格子边长大约 0.01 度,在纬度 30 度附近大约是 1.1 公里。订单的取货点落在哪个格子,就优先分配给当前在该格子或相邻格子的骑手。

import math GRID_SIZE = 0.01 # 约 1.1km x 1.1km def get_grid_key(lat, lng): """把经纬度映射到网格编号""" grid_x = int(math.floor(lng / GRID_SIZE)) grid_y = int(math.floor(lat / GRID_SIZE)) return (grid_x, grid_y) def get_neighbor_grids(grid_key): """返回当前网格及周围8个网格""" gx, gy = grid_key neighbors = [] for dx in (-1, 0, 1): for dy in (-1, 0, 1): neighbors.append((gx + dx, gy + dy)) return neighbors

逻辑说明:get_grid_key 把连续的经纬度离散成网格编号,方便做区域聚合。get_neighbor_grids 返回九宫格范围,调度时先看当前格子有没有空闲骑手,没有再看相邻格子。这样既保证了区域就近,又不会因为格子边界把骑手和订单硬生生隔开。

参数说明:GRID_SIZE 是核心调参点。设得太小,格子太细,骑手经常跨格,调度频繁;设得太大,一个格子覆盖几公里,就近原则就失效了。我一般会按城市路网密度调整,市区用 0.005,郊区用 0.02。这个值没有绝对标准,跑几天看骑手平均取货距离就能判断合不合适。

3.2 骑手负载计算与分配优先级排序

光看距离不够,还得看骑手手上已经有多少单。一个已经接了四单的骑手,再给他派一单,大概率会超时。资源包里的分配逻辑是给每个候选骑手算一个分数,分数越低越优先。

def calc_rider_score(rider, order, grid_key): """计算骑手接单优先级分数,越低越优先""" # 距离分:骑手当前位置到取货点的直线距离(公里) dist = haversine(rider['current_lat'], rider['current_lng'], order['pickup_lat'], order['pickup_lng']) # 负载分:当前在手单量占最大接单量的比例 load_ratio = rider['active_count'] / max(rider['max_orders'], 1) # 区域分:骑手是否在订单所在网格或相邻网格 rider_grid = get_grid_key(rider['current_lat'], rider['current_lng']) area_penalty = 0 if rider_grid in get_neighbor_grids(grid_key) else 5 # 加权求和:距离权重 0.6,负载权重 3.0,区域惩罚 5.0 score = dist * 0.6 + load_ratio * 3.0 + area_penalty return score def haversine(lat1, lng1, lat2, lng2): """计算两点间球面距离,单位公里""" R = 6371 dlat = math.radians(lat2 - lat1) dlng = math.radians(lng2 - lng1) a = (math.sin(dlat/2)**2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlng/2)**2) return R * 2 * math.asin(math.sqrt(a))

逻辑说明:距离分用 haversine 算球面距离,比欧氏距离准确。负载分用比例而不是绝对值,这样不同 max_orders 的骑手可以公平比较。区域惩罚是硬性的,不在附近直接加 5 分,基本就排除了。三个权重是经验值,距离权重低是因为同城场景下骑手移动速度快,距离差异影响没有负载大。

参数说明:0.6、3.0、5.0 这三个权重需要根据实际数据调。如果发现骑手跑太远,把距离权重提到 1.0 以上;如果发现有的骑手累死有的闲死,把负载权重提到 5.0。区域惩罚 5.0 是个门槛值,确保跨区骑手基本不会被选中,除非附近真的没人。

3.3 批量派单的 SQL 实现与事务处理

单个订单分配好写,但高峰期订单是批量进来的。资源包里用了一个批量派单的存储过程思路,在应用层用事务包起来。

import sqlite3 def batch_dispatch(db_path): conn = sqlite3.connect(db_path) conn.execute("PRAGMA journal_mode=WAL") cur = conn.cursor() # 取出所有待分配订单,按优先级降序 cur.execute(""" SELECT order_id, pickup_lat, pickup_lng, priority FROM orders WHERE status = 0 ORDER BY priority DESC, created_at ASC """) pending_orders = cur.fetchall() # 取出所有可接单骑手 cur.execute(""" SELECT rider_id, current_lat, current_lng, active_count, max_orders FROM riders WHERE status IN (1, 2) AND active_count < max_orders """) riders = cur.fetchall() assignments = [] for order in pending_orders: order_id, plat, plng, _ = order grid_key = get_grid_key(plat, plng) best_rider = None best_score = float('inf') for r in riders: rider = { 'rider_id': r[0], 'current_lat': r[1], 'current_lng': r[2], 'active_count': r[3], 'max_orders': r[4] } score = calc_rider_score(rider, {'pickup_lat': plat, 'pickup_lng': plng}, grid_key) if score < best_score: best_score = score best_rider = rider if best_rider: assignments.append((best_rider['rider_id'], order_id)) # 更新内存中的骑手负载,避免同一批里重复分配 for r in riders: if r[0] == best_rider['rider_id']: riders[riders.index(r)] = ( r[0], r[1], r[2], r[3] + 1, r[4] ) break # 事务写入 try: for rider_id, order_id in assignments: cur.execute(""" UPDATE orders SET status = 1, rider_id = ?, assigned_at = strftime('%s','now') WHERE order_id = ? AND status = 0 """, (rider_id, order_id)) cur.execute(""" UPDATE riders SET active_count = active_count + 1, status = 2, updated_at = strftime('%s','now') WHERE rider_id = ? """, (rider_id,)) conn.commit() except Exception as e: conn.rollback() raise e finally: conn.close() return len(assignments)

逻辑说明:先按优先级和时间排序取出待分配订单,保证加急单先派。骑手列表在内存中维护,每分配一单就更新内存中的 active_count,避免同一批里把同一个骑手派爆。写入时用事务包住,任何一条失败就全部回滚。UPDATE 语句里带了AND status = 0条件,防止并发情况下重复分配。

参数说明:status IN (1, 2)表示只选空闲和忙碌但未满的骑手。active_count < max_orders是硬性过滤。事务里的 rollback 是必须的,SQLite 虽然单机,但应用层可能有多个线程同时调这个函数,不加事务会出脏数据。

4. 状态同步与离线容错:骑手端数据回传的坑

4.1 弱网环境下状态更新的队列机制

骑手端最现实的问题不是算法,是网络。电梯里没信号、地下车库没信号、老小区信号差,这些场景下骑手点了“已取货”但请求发不出去,如果直接丢请求,调度中心就不知道这单已经取走了。资源包里的做法是在骑手端维护一个本地 SQLite 队列,所有状态变更先写本地,再异步同步。

-- 骑手端本地队列表 CREATE TABLE sync_queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id TEXT NOT NULL, action TEXT NOT NULL, -- pickup / deliver / cancel payload TEXT, -- JSON 格式的附加数据 created_at INTEGER NOT NULL, retry_count INTEGER DEFAULT 0, synced INTEGER DEFAULT 0 -- 0未同步 1已同步 );

逻辑说明:骑手每次操作先往 sync_queue 插一条记录,然后后台线程定时扫描synced = 0的记录往服务端发。发送成功就把 synced 置 1,失败就 retry_count 加一,超过阈值告警。这样即使断网半小时,恢复后也能把积压的状态全部补上。

参数说明:retry_count 阈值我一般设 10,超过就标记为异常单让人工介入。payload 字段存 JSON 是为了兼容不同 action 的附加参数,比如取消原因、异常照片的本地路径等。

4.2 服务端幂等处理与冲突合并

骑手端补传的数据可能重复,也可能顺序颠倒。比如“已取货”和“已送达”两条记录,因为重试机制可能送达先到、取货后到。服务端必须做幂等和顺序校正。

def apply_status_update(conn, order_id, action, client_ts): """应用骑手端状态更新,保证幂等和顺序正确""" cur = conn.cursor() cur.execute("SELECT status, assigned_at FROM orders WHERE order_id = ?", (order_id,)) row = cur.fetchone() if not row: return False current_status, assigned_at = row # 状态机:只允许向前推进,不允许回退 action_map = {'pickup': 2, 'deliver': 4, 'cancel': 5} new_status = action_map.get(action) if new_status is None: return False if new_status <= current_status and new_status != 5: return True # 已经处理过,幂等返回 # 用客户端时间戳做顺序校正,防止乱序覆盖 cur.execute(""" UPDATE orders SET status = ?, finished_at = ? WHERE order_id = ? AND status < ? """, (new_status, client_ts, order_id, new_status)) conn.commit() return cur.rowcount > 0

逻辑说明:状态机只允许向前推进,pickup 之后不能回到待分配,deliver 之后不能回到配送中。new_status <= current_status时直接返回 True,表示这条更新已经生效过,幂等处理。UPDATE 语句里带AND status < ?条件,防止并发写入时旧状态覆盖新状态。

参数说明:client_ts 用骑手端本地时间戳,服务端不信任但用来做排序参考。cancel 状态特殊处理,允许从任何状态跳到 5。finished_at 只在 deliver 时写入,其他状态不更新这个字段。

4.3 轨迹数据压缩与定期归档

骑手位置上报频率高的话,轨迹表会迅速膨胀。一个骑手一天跑 8 小时,每 10 秒上报一次,就是 2880 条记录。一百个骑手就是 28 万条。资源包里的做法是:实时轨迹只保留最近 7 天,超过 7 天的做抽稀归档。

-- 归档 7 天前的轨迹,每分钟只保留一条 INSERT INTO rider_tracks_archive (rider_id, lat, lng, reported_at) SELECT rider_id, lat, lng, MIN(reported_at) FROM rider_tracks WHERE reported_at < strftime('%s','now') - 7*86400 GROUP BY rider_id, CAST(reported_at / 60 AS INTEGER); -- 删除已归档的原始数据 DELETE FROM rider_tracks WHERE reported_at < strftime('%s','now') - 7*86400;

逻辑说明:归档时按骑手和分钟分组,每分钟只留最早的一条,这样轨迹密度从 10 秒一条降到 1 分钟一条,数据量降到原来的六分之一,但路径形状基本保留。归档和删除放在同一个事务里执行,避免中间状态。

参数说明:7 天是热数据窗口,根据磁盘空间调整。86400 是一天的秒数。CAST(reported_at / 60 AS INTEGER) 把时间戳按分钟取整,SQLite 的整数除法会自动截断。

5. 避坑与排查:SQLite 调度系统最容易翻车的五个点

5.1 现象:高峰期写入报 “database is locked”

原因:没有开 WAL 模式,或者开了 WAL 但多个线程共用一个连接。SQLite 默认的 DELETE 日志模式下,写操作会锁全库,读操作全部阻塞。即使开了 WAL,如果多个线程复用一个 connection 对象,也会因为 Python 的 GIL 和 SQLite 的线程模型冲突导致锁等待。

解决:第一,确认PRAGMA journal_mode=WAL;已经执行,用PRAGMA journal_mode;查一下返回值是不是 wal。第二,每个线程用独立的 connection,不要跨线程共享。第三,设置PRAGMA busy_timeout=5000;,让写操作在锁冲突时等待 5 秒而不是立刻报错。

5.2 现象:订单分配后骑手端看不到

原因:调度服务写入了 orders 表,但骑手端查的是本地缓存或者另一个数据库文件。SQLite 是文件级数据库,两个进程打开同一个文件才能看到彼此的数据。如果骑手端用的是自己本地的 SQLite 文件,那服务端的分配结果必须通过接口推送过去。

解决:确认调度服务和骑手端查的是同一个数据库文件路径。如果是分离部署,服务端分配后要主动推送到骑手端,或者骑手端定时拉取。资源包里默认是单机同文件方案,如果改成多端,需要加一层同步逻辑。

5.3 现象:轨迹表越来越大,查询越来越慢

原因:rider_tracks 表只增不删,索引虽然能加速查询,但数据量到千万级之后,即使有索引,范围查询也会变慢。而且 SQLite 的单文件在超过几 GB 之后,备份和复制都会变得很慢。

解决:按 4.3 节的方案做定期归档和删除。另外可以给 rider_tracks 表按月份分表,比如 rider_tracks_202501、rider_tracks_202502,查询时按时间范围选表。SQLite 的 ATTACH DATABASE 可以跨文件查询,分表后每个文件小很多。

5.4 现象:批量派单时部分订单状态没更新

原因:批量派单的事务里,如果某一条 UPDATE 因为并发被阻塞或者条件不满足(比如 status 已经不是 0 了),后面的语句可能被跳过。Python 的 sqlite3 模块默认是自动提交模式,如果不显式 begin transaction,每条语句都是独立事务。

解决:在批量操作前显式执行conn.execute("BEGIN"),所有操作完成后conn.commit(),异常时conn.rollback()。另外 UPDATE 语句一定要带AND status = 0这样的条件,确保不会覆盖已经被其他线程改过的记录。

5.5 现象:骑手端补传数据后订单状态错乱

原因:骑手端队列里的操作没有按时间排序发送,或者服务端没有做状态机校验,导致“已送达”先于“已取货”被应用,订单直接从待分配跳到已完成。

解决:骑手端发送队列时按 created_at 升序排列。服务端在 apply_status_update 里做状态机校验,只允许向前推进。对于乱序到达的旧状态,直接丢弃并记录日志。如果发现某订单状态跳跃,人工介入检查。

6. 进阶技巧:用 SQLite 的 EXPLAIN 和 WAL 检查点做性能验证

调度系统跑起来之后,怎么确认它真的在高效运转?我一般会做两件事:用 EXPLAIN QUERY PLAN 看关键查询有没有走索引,用 WAL 检查点控制日志文件大小。

先看查询计划。批量派单里最核心的查询是取待分配订单和取可用骑手,这两条必须走索引。

EXPLAIN QUERY PLAN SELECT order_id, pickup_lat, pickup_lng, priority FROM orders WHERE status = 0 ORDER BY priority DESC, created_at ASC;

如果输出里出现SCAN orders而不是SEARCH orders USING INDEX idx_orders_status,说明索引没生效。常见原因是 status 字段的区分度太低,比如 90% 的订单都是 status=0,SQLite 优化器可能觉得全表扫描更快。这时候可以强制走索引:SELECT ... FROM orders INDEXED BY idx_orders_status WHERE status = 0 ...。但更好的做法是加一个复合索引CREATE INDEX idx_orders_status_priority ON orders(status, priority DESC, created_at ASC);,让排序也在索引里完成。

再看 WAL 检查点。WAL 模式下,写操作先追加到-wal文件,读操作从主文件和 WAL 文件合并读取。WAL 文件会一直增长,直到触发检查点才把数据合并回主文件。默认的自动检查点是每 1000 页触发一次,但在高频写入场景下,WAL 文件可能涨到几百 MB。

-- 查看当前 WAL 文件大小和检查点状态 PRAGMA wal_checkpoint; -- 手动执行 TRUNCATE 检查点,把 WAL 文件截断到 0 PRAGMA wal_checkpoint(TRUNCATE);

我一般会在每天凌晨低峰期跑一次PRAGMA wal_checkpoint(TRUNCATE);,把 WAL 文件清空。注意这个操作会短暂阻塞写入,所以别在高峰期做。另外可以设置PRAGMA journal_size_limit=67108864;(64MB),让 SQLite 在检查点后自动截断 WAL 文件到指定大小。

还有一个实用技巧:用PRAGMA optimize;定期让 SQLite 自动分析表和索引的统计信息。这个命令在连接关闭前执行一次就行,它会根据查询模式决定是否需要 ANALYZE。我习惯在调度服务每天重启前跑一遍,跑完之后 EXPLAIN 的输出会更准。

最后说一个我踩过的坑:SQLite 的PRAGMA synchronous在 WAL 模式下设成 NORMAL 是安全的,但如果设成 OFF,虽然写入飞快,但断电时可能丢最近几条事务。同城配送的订单状态丢了是要赔钱的,所以这个参数我从来不敢设 OFF。从那以后我每次初始化数据库,都会把 journal_mode、synchronous、busy_timeout、journal_size_limit 这四个参数写进一个 init.sql 里,强制走一遍,确认输出符合预期才继续。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询