SQLite单文件驱动同城物流派送系统:数据模型、状态机与派单调度实战
简介这份资源是面向Android初学者与移动应用开发练习者的同城物流派送系统项目源码围绕城市内快速配送场景完整呈现从订单发起到司机抢单、再到货物送达的业务闭环可用于学习SQLite数据存储、账号注册登录、自定义适配器列表展示、抢单与派送状态流转以及GPS定位与路线规划等典型模块的实现思路。压缩包共275个文件约53.42MB以94个png界面素材、86个class编译文件、39个xml布局与配置、31个java源码为主另含9个jar依赖、5个so库及apk安装包等兼顾源码阅读与直接运行体验。目前已有323人学习下载。读者可从中获得一套结构完整的项目参考对照数据库增删改查、用户认证与适配器数据绑定等环节理解Android应用的组织方式适合作为课程设计或毕业设计的实践蓝本。1. 同城物流派送系统拆解一个 SQLite 单文件撑起整套调度逻辑同城物流派送这类项目最容易被做复杂订单表、骑手表、路线表、状态机、结算流水一上来就上 MySQL 加 Redis本地跑个 Demo 还得先装服务、配账号、开端口。我这次拆的这份资源走的是另一条路——整套派送调度逻辑压在一个 SQLite 单文件里订单落库、骑手状态流转、派单匹配、轨迹点记录全在一个.db文件内完成拷走就能跑换台机器不用改一行连接配置。它解决的是中小规模同城配送场景下「数据量不大但事务一致性要求不低」的问题适合想快速验证派单算法、做课程设计、或者给门店做轻量调度工具的从业者。SQLite 在这里不是妥协是选型。2. 数据模型与派单状态机先把表设计对后面少改三次代码拿到这类资源我一般不看界面先看表结构。同城派送的复杂度八成压在数据模型上订单从创建到签收要经过哪些状态、骑手接单后订单和骑手怎么绑定、派单失败怎么回滚这些想不清楚后面每加一个功能都要动表。这份资源的表设计不算多但把关键关系收住了值得先讲透再动手。2.1 四张核心表与它们的外键关系资源里的核心表大致是四张订单表orders、骑手表couriers、派单记录表assignments、轨迹点表track_points。订单表存收货地址经纬度、期望时段、当前状态骑手表存当前位置、载单上限、在线状态派单记录表是订单和骑手的多对多桥接带派单时间和结果轨迹点表按时间戳追加骑手位置。关键设计点在assignments这张桥接表它没有把courier_id直接塞进orders而是单独记录每一次派单尝试包括失败的那次。这个选择很关键——同城派送里「派了但骑手拒单」是高频事件如果只在订单表上挂一个骑手字段拒单后历史就丢了事后排查「这单为什么绕了三圈才派出去」没有依据。桥接表让每次尝试都留痕代价是多一次 join。-- 订单表状态用整数枚举避免字符串比较 CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, pickup_lat REAL NOT NULL, pickup_lng REAL NOT NULL, drop_lat REAL NOT NULL, drop_lng REAL NOT NULL, status INTEGER NOT NULL DEFAULT 0, -- 0待派 1已派 2取货中 3配送中 4已签收 5已取消 created_at INTEGER NOT NULL, -- Unix 秒统一时区 expect_start INTEGER, -- 期望送达时段起点 expect_end INTEGER ); -- 派单记录每次尝试一行失败也留痕 CREATE TABLE assignments ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER NOT NULL REFERENCES orders(id), courier_id INTEGER NOT NULL REFERENCES couriers(id), result INTEGER NOT NULL, -- 0待响应 1接受 2拒单 3超时 assigned_at INTEGER NOT NULL, responded_at INTEGER ); CREATE INDEX idx_assign_order ON assignments(order_id); CREATE INDEX idx_assign_courier ON assignments(courier_id, result);上面这段建表语句里有两个参数值得说。status用整数而不是文本是因为 SQLite 没有原生枚举文本状态在批量更新时容易拼错且索引效率低整数配合应用层常量最稳。created_at和expect_start统一存 Unix 秒而不是文本时间是为了后面算「距离期望时段还有多久」时能直接做减法不用在 SQL 里调日期函数——SQLite 的日期函数跨时区处理是玄学能避开就避开。两个索引分别服务「查某订单的派单历史」和「查某骑手当前有效单」这两个查询在派单循环里会被高频调用。2.2 状态流转与事务边界状态机是这类系统最容易翻车的地方。订单状态从 0 到 4 是一条单向链但取消5可以从任意前置状态进入骑手拒单则要把订单从 1 退回 0。这份资源把状态变更全部收在一个函数里不允许业务代码直接UPDATE orders SET status这个约束我认可。import sqlite3 # 合法状态迁移当前状态 - 允许的下一状态集合 TRANSITIONS { 0: {1, 5}, # 待派 - 已派 / 取消 1: {2, 0, 5}, # 已派 - 取货中 / 退回待派(拒单) / 取消 2: {3, 5}, # 取货中 - 配送中 / 取消 3: {4, 5}, # 配送中 - 已签收 / 取消 4: set(), # 终态 5: set(), } def change_status(conn, order_id, new_status): cur conn.cursor() cur.execute(SELECT status FROM orders WHERE id?, (order_id,)) row cur.fetchone() if row is None: raise ValueError(forder {order_id} not found) old row[0] if new_status not in TRANSITIONS.get(old, set()): raise ValueError(fillegal transition {old} - {new_status}) cur.execute( UPDATE orders SET status? WHERE id? AND status?, (new_status, order_id, old), # 带旧状态做乐观锁 ) if cur.rowcount ! 1: raise RuntimeError(concurrent status change detected) conn.commit()这段代码的逻辑说明先查当前状态用TRANSITIONS字典校验迁移合法性非法迁移直接抛错而不是静默忽略——静默忽略是血泪经验线上出问题时你根本不知道哪一步被吞了。UPDATE语句里带AND status?是乐观锁防止两个并发请求同时把订单从 1 改到 2 和 0rowcount不等于 1 就说明有人抢先改了抛错让上层重试。参数上conn由调用方传入而不是函数内新建是为了让多个状态变更能包在同一个事务里如果你要一次改多张表把commit提到外层。提示SQLite 默认的isolation_level是隐式事务Python 的sqlite3模块在 DML 前会自动开事务。上面代码里显式commit是必要的但如果你在外层已经开了事务这里的commit会提前提交需要改成由外层统一控制。2.3 派单匹配的 SQL 写法派单的核心查询是「找一批在线、未满载、距离取货点最近的骑手」。这份资源没有上空间索引用的是经纬度平方差近似排序在同城几公里范围内误差可以接受。-- 候选骑手在线、载单未满、按到取货点距离平方升序 SELECT c.id, (c.lat - :pickup_lat) * (c.lat - :pickup_lat) (c.lng - :pickup_lng) * (c.lng - :pickup_lng) AS dist2 FROM couriers c WHERE c.online 1 AND c.load_count c.max_load AND c.id NOT IN ( SELECT courier_id FROM assignments WHERE order_id :order_id AND result IN (1, 0) -- 已接受或待响应 ) ORDER BY dist2 ASC LIMIT 5;逻辑说明dist2是距离平方省掉开方排序结果和真实距离一致但快一点。NOT IN子查询排除掉已经对这笔订单接受或待响应的骑手避免重复派给同一个人。LIMIT 5是候选池大小实际派单时按顺序逐个发请求前一个拒单再试下一个。参数:pickup_lat这类命名占位符比?可读性好Python 里用字典传参即可。注意load_count是冗余字段每次接单要同步加一这个一致性靠应用层保证SQLite 没有触发器约束的话容易漏——这是后面避坑章要展开的点。3. 从零跑通派单闭环建库、灌数据、模拟一轮调度表设计讲完接下来是让它动起来。这一章按「建库 → 灌测试数据 → 跑一轮派单 → 看结果」的顺序走每一步都能直接抄。我一般会先造一批假数据把闭环跑通再谈优化不然连状态流转对不对都验证不了。3.1 初始化数据库与 PRAGMA 调优SQLite 默认配置偏保守同城派送这种写多读也多的小系统开库时设几个 PRAGMA 能明显减少锁等待。import sqlite3 def open_db(pathdispatch.db): conn sqlite3.connect(path, timeout5.0) cur conn.cursor() cur.execute(PRAGMA journal_modeWAL;) # 读写不互斥 cur.execute(PRAGMA synchronousNORMAL;) # 兼顾安全与速度 cur.execute(PRAGMA foreign_keysON;) # 外键约束默认是关的 cur.execute(PRAGMA busy_timeout5000;) # 锁等待 5 秒 conn.commit() return conn参数说明journal_modeWAL让读操作不被写操作阻塞派单循环里一边写派单记录一边查骑手列表不开 WAL 会频繁database is locked。synchronousNORMAL在 WAL 模式下已经足够安全比默认FULL快不少代价是极端断电可能丢最后一个事务——同城派送场景可以接受。foreign_keysON必须显式开SQLite 默认不校验外键不开的话assignments里能插进不存在的order_id脏数据就是这么来的。busy_timeout是等锁的超时设 5 秒比默认立即报错友好。3.2 灌入模拟订单与骑手测试数据要覆盖边界有骑手满载、有骑手离线、有订单在期望时段边缘。下面这段造 20 个骑手、50 个订单坐标在一个虚构城区的矩形范围内随机。import random, time def seed(conn, n_couriers20, n_orders50): cur conn.cursor() now int(time.time()) for i in range(n_couriers): cur.execute( INSERT INTO couriers(lat,lng,online,load_count,max_load) VALUES(?,?,?,?,?), (30.0 random.random(), 120.0 random.random(), 1 if random.random() 0.2 else 0, # 两成离线 0, random.choice([2, 3, 4])), ) for i in range(n_orders): cur.execute( INSERT INTO orders(pickup_lat,pickup_lng,drop_lat,drop_lng,status,created_at,expect_start,expect_end) VALUES(?,?,?,?,0,?,?,?), (30.0 random.random(), 120.0 random.random(), 30.0 random.random(), 120.0 random.random(), now, now 1800, now 3600), ) conn.commit()逻辑说明online用random.random() 0.2让约两成骑手离线是为了验证派单查询的WHERE online1真的生效。max_load在 2 到 4 之间随机制造满载边界。订单的expect_start设在 30 分钟后、expect_end在 1 小时后方便后面测「临近超时优先派」的逻辑。坐标范围是虚构的实际用时换成你所在城区的经纬度矩形即可。3.3 跑一轮派单并核对结果派单主循环的逻辑是取待派订单按期望时段紧迫度排序对每单查候选骑手逐个尝试成功则更新订单和骑手。def dispatch_round(conn): cur conn.cursor() cur.execute(SELECT id,pickup_lat,pickup_lng FROM orders WHERE status0 ORDER BY expect_end ASC) orders cur.fetchall() for oid, plat, plng in orders: cur.execute( SELECT c.id FROM couriers c WHERE c.online1 AND c.load_count c.max_load AND c.id NOT IN (SELECT courier_id FROM assignments WHERE order_id? AND result IN (0,1)) ORDER BY (c.lat-?)*(c.lat-?)(c.lng-?)*(c.lng-?) ASC LIMIT 5 , (oid, plat, plat, plng, plng)) candidates [r[0] for r in cur.fetchall()] for cid in candidates: try: cur.execute(BEGIN) cur.execute(INSERT INTO assignments(order_id,courier_id,result,assigned_at) VALUES(?,?,0,?), (oid, cid, int(time.time()))) cur.execute(UPDATE orders SET status1 WHERE id? AND status0, (oid,)) if cur.rowcount ! 1: raise RuntimeError(order taken) cur.execute(UPDATE couriers SET load_countload_count1 WHERE id? AND load_countmax_load, (cid,)) if cur.rowcount ! 1: raise RuntimeError(courier full) conn.commit() break except Exception: conn.rollback() continue逻辑说明外层按expect_end升序越临近超时的订单越先派。内层对每个候选骑手开一个事务插入派单记录、改订单状态、加骑手载单数三步要么全成要么全回滚。UPDATE orders ... AND status0和UPDATE couriers ... AND load_countmax_load都带条件rowcount校验是并发下的后悔药——两个派单线程同时处理同一单或同一骑手时第二个会失败并回滚。参数上LIMIT 5控制候选池太大浪费查询太小可能派不出去5 是个经验值。跑完一轮后查SELECT status, COUNT(*) FROM orders GROUP BY status就能看到派出去多少、还剩多少待派。4. 避坑与排查SQLite 派送系统最容易翻车的五个点这套东西跑通不难难的是跑稳。下面五条是我在类似项目里真实踩过或见别人踩过的按「现象 → 原因 → 解决」写遇到对应症状直接对号入座。4.1 现象派单循环跑几百单后报database is locked原因没开 WAL或者开了 WAL 但多个连接同时写。SQLite 同一时刻只允许一个写者派单循环里如果每个订单都新开连接写锁竞争会非常激烈。另一个常见原因是事务没及时提交长事务占着写锁。解决开库时统一走open_db()设journal_modeWAL和busy_timeout整个派单循环复用同一个连接不要每单新建事务粒度控制在「一单一次」别把整轮派单包成一个大事务。如果确实要多线程写操作串行化读可以并发。4.2 现象骑手load_count和实际有效单数对不上原因load_count是冗余字段接单时加一、签收或取消时减一漏了任何一处就会漂移。最常见的是订单取消时忘了减或者拒单回滚时减了两次。解决不要靠人工记增减点写一个对账函数定期重算UPDATE couriers SET load_count(SELECT COUNT(*) FROM assignments a JOIN orders o ON a.order_ido.id WHERE a.courier_idcouriers.id AND a.result1 AND o.status IN (1,2,3))。更稳的做法是干脆不存load_count每次派单时用子查询实时算数据量小的时候性能完全够省掉一致性维护。4.3 现象同一订单被派给两个骑手两人都来取货原因派单时先查候选再插入查和插之间没有锁两个调度线程同时处理同一单都查到空候选然后各自插入。这是典型的检查后使用竞态。解决插入派单记录前先UPDATE orders SET status1 WHERE id? AND status0用rowcount判断是否抢到抢到再插记录或者给assignments加唯一约束UNIQUE(order_id, courier_id, result)配合事务。核心是让「占单」这个动作原子化别把判断和写入分开。4.4 现象按距离排序结果和地图上看到的顺序不一致原因用经纬度平方差排序在纬度跨度大或者靠近极地时误差明显另外如果经纬度存的是文本或者混了不同坐标系比如有的点存了偏移后的坐标排序直接失效。解决同城几公里范围内平方差够用但前提是所有坐标同一坐标系、同一单位。入库前统一转成 WGS84 的十进制度别混。跨度超过十几公里时改用 Haversine 公式或者上 R-Tree 索引SQLite 自带rtree模块建虚拟表按矩形范围先筛再精排。4.5 现象程序重启后部分订单状态卡在「已派」但骑手说没收到原因派单记录插了、订单状态改了但通知骑手的动作在事务外通知失败后事务已提交状态和实际不一致。或者反过来通知发了但事务回滚了。解决把「通知」做成可重试的独立步骤订单状态里加一个「已派待确认」骑手确认后才转「取货中」。通知失败时靠定时任务扫「已派待确认」超时的单重新派。别指望一次通知必达同城派送的常态就是重试。5. 进阶用触发器和对账任务把一致性焊死前面几章把闭环跑通了但冗余字段和状态一致性还是靠应用层自觉。真要在生产边缘用我一般会加两层保险一层是 SQLite 触发器兜底一层是定时对账任务。触发器负责「该减的必须减」对账负责「已经漂的拉回来」两层配合基本不用再担心数据对不上。先看触发器。订单状态从「已派/取货中/配送中」进入终态签收或取消时对应骑手的load_count必须减一。用AFTER UPDATE触发器实现CREATE TRIGGER trg_order_release_load AFTER UPDATE OF status ON orders WHEN OLD.status IN (1,2,3) AND NEW.status IN (4,5) BEGIN UPDATE couriers SET load_count MAX(load_count - 1, 0) WHERE id ( SELECT courier_id FROM assignments WHERE order_id NEW.id AND result 1 ORDER BY assigned_at DESC LIMIT 1 ); END;逻辑说明AFTER UPDATE OF status只在状态列被改时触发避免无关更新误触。WHEN子句限定「从进行中进入终态」重复触发不会重复减。MAX(load_count - 1, 0)防止减成负数这是兜底中的兜底。子查询取最近一次接受的派单记录因为一单可能被派过多次只有最后接受的那个骑手该减。参数上没有可调的但要注意触发器在事务内执行如果外层事务回滚触发器效果也回滚这点和普通语句一致。再看对账任务。触发器能覆盖大部分路径但历史脏数据、手工改库、触发器创建前的数据都管不到所以需要一个定时跑的校正脚本def reconcile(conn): cur conn.cursor() cur.execute( UPDATE couriers SET load_count ( SELECT COUNT(*) FROM assignments a JOIN orders o ON a.order_id o.id WHERE a.courier_id couriers.id AND a.result 1 AND o.status IN (1,2,3) ) ) conn.commit() return cur.rowcount这个脚本把每个骑手的load_count重算成「当前有效单数」rowcount返回被修正的行数可以打到日志里监控漂移频率。我一般设成每 10 分钟跑一次或者每次派单批次结束后跑一次。如果rowcount长期大于 0说明触发器有漏网路径得回去查。验证方法也简单造一批订单随机走完签收和取消然后跑一次reconcile看rowcount是否为 0。不为 0 就说明有路径没被触发器覆盖。我习惯在每次改状态机代码后都跑一遍这个验证比肉眼看代码靠谱。注意触发器会增加写操作开销派单高峰期如果发现写入变慢先查是不是触发器里的子查询没走索引。assignments(order_id)和assignments(courier_id, result)这两个索引就是为它准备的别删。从那以后我每次给这类单文件数据库加冗余字段都强制先写触发器再写业务代码最后补一个对账脚本兜底——顺序反了后面补一致性就是无底洞。希望帮到你。本文还有配套的精品资源点击获取