多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

SQLite单机方案如何支撑同城物流派送系统:表结构、调度与离线容错

SQLite单机方案如何支撑同城物流派送系统:表结构、调度与离线容错 简介这是一份面向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_modeWAL; PRAGMA synchronousNORMAL; PRAGMA foreign_keysON; .read schema.sql EOF逻辑说明journal_modeWAL 是必须的默认的 DELETE 模式在写的时候会锁全库读操作全部阻塞。synchronousNORMAL 在 WAL 模式下是安全且性能较好的折中如果对数据安全性要求极高可以设 FULL但写入延迟会明显上升。foreign_keysON 默认是关闭的需要显式打开。参数说明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 status0;确认待分配订单能正常查出。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_modeWAL) 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_modeWAL;已经执行用PRAGMA journal_mode;查一下返回值是不是 wal。第二每个线程用独立的 connection不要跨线程共享。第三设置PRAGMA busy_timeout5000;让写操作在锁冲突时等待 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% 的订单都是 status0SQLite 优化器可能觉得全表扫描更快。这时候可以强制走索引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_limit67108864;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 里强制走一遍确认输出符合预期才继续。希望帮到你。本文还有配套的精品资源点击获取
返回列表